diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7bf2205a5..8925b4a3c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -111,7 +111,7 @@ The Rust workspace (`src/`) implements multiple sandboxing backends behind the ` | Backend | Binary | Platform | Module | |---------|--------|----------|--------| | AppContainer | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/appcontainer_runner.rs` | -| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` — prefers `CreateProcessSecurityEnvironment` with PSEC whenever its runtime probe succeeds and the requested policy is compatible, independent of schema version. It temporarily falls back to `Experimental_CreateProcessInSandbox` with the SBOX FlatBuffer contract when PSEC is unavailable or policy-incompatible, then retains the AppContainer tier fallback. Proxy requests use legacy SBOX only on query-less hosts; capability-aware SBOX hosts fall back to AppContainer until MXC can author the model-2 AppContainer-peer contract. `captureDenials` still requires the official V2 PSEC + Learning Mode exports and cannot use a lower tier. | +| BaseContainer (OS sandbox API) | `wxc-exec.exe` | Windows | `backends/appcontainer/common/src/base_container_runner.rs` — prefers `CreateProcessSecurityEnvironment` with PSEC whenever its runtime probe succeeds and the requested policy is compatible, independent of schema version. It temporarily falls back to `Experimental_CreateProcessInSandbox` with the SBOX FlatBuffer contract when PSEC is unavailable or policy-incompatible, then retains the AppContainer tier fallback. Proxy requests use legacy SBOX only on query-less hosts; capability-aware SBOX hosts fall back to AppContainer until MXC can author the model-2 AppContainer-peer contract. `captureDenials` prefers the complete compatible PSEC + V2 Learning Mode path; when that path cannot fully honor a request, MXC retains the highest compatible legacy tier and pairs it with guarded WPR using exact handle-attested process scope. | | Windows Sandbox | `wxc-exec.exe` | Windows | `backends/windows_sandbox/lifecycle/src/` (live transient one-shot `WindowsSandboxRunner` + state-aware `StatefulSandboxBackend`). Experimental — requires `--experimental`. Supports both **one-shot** (a fresh, disposable VM per invocation with guaranteed teardown, via `ScriptRunner`) and **state-aware** (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. State-aware holds a single live VM across separate `wxc-exec` phase processes behind a persistent detached host-side daemon (`backends/windows_sandbox/daemon/`); the OS enforces a single running Windows Sandbox VM per host, so the daemon owns it and reclaims an orphaned VM on restart only via positive process-identity proof. The shared boot sequence (write per-launch nonce, launch VM, capture ownership proof, wait rendezvous, connect) lives in `backends/windows_sandbox/lifecycle/src/vm.rs::launch_managed_vm`; each mode plugs in its own `LaunchObserver` for the per-caller ownership / proof bookkeeping. Honors `readwritePaths`/`readonlyPaths`/`deniedPaths` (HOST paths) at provision via `.wsb` `` entries (mapped at the same absolute host path inside the guest; rejects `deniedPaths` equal-to or nested-within a mapped share since `.wsb` has no Deny primitive); filesystem policy is immutable post-provision. Network isolation is enforced unconditionally by the in-guest agent; `network`/`ui` are not honored. ID prefix `wsb` (strict `wsb:<8-hex>` grammar). Per-launch handshake: 32-byte `Nonce` + 1-byte `ChannelRole` tag on every TCP connection (boot + reconnect); the guest pairs accepted sockets by declared role, not by accept order. The guest agent binary `wxc-windows-sandbox-guest.exe` (`backends/windows_sandbox/guest/`) is injected into the VM. | | MicroVM (NanVix) | `wxc-exec.exe` | Windows | `backends/nanvix/runner/src/lib.rs` — feature-gated behind `microvm` | | Hyperlight | `wxc-exec.exe` | Windows | `backends/hyperlight/common/src/lib.rs` — Hyperlight + Unikraft micro-VM backend | @@ -206,7 +206,7 @@ The workspace is organized into six top-level directories under `src/`: - Each Windows containment backend lives in its own `backends/*/common` crate (e.g. `appcontainer_common`, `windows_sandbox_common`, `isolation_session_common`, `hyperlight_common`, `nanvix_runner`). Backend crates depend on `wxc_common`; there are no cross-edges between backend crates. Windows Sandbox additionally has `windows_sandbox_lifecycle`, which owns the one-shot and state-aware runners and depends on `windows_sandbox_common` for the wire protocol, plus separate daemon and guest binaries. - `learning_mode_core` is the cross-platform learning-mode denial model and output layer. It owns denial types, summaries, analyzer abstractions, plain-JSON document emission, and the serializable output-pointer type, and must not depend on any `backends/*` crate. - `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, decodes sealed ETL traces through `learning_mode_core`, and depends on `wxc_common` plus `learning_mode_core`; runner integration consumes it from the AppContainer backend layer. The trace contract is `HRESULT Start` + retryable `HRESULT Stop` + infallible `Close`: `Stop` never consumes the trace handle, and every started trace must be closed exactly once (closing without stopping is the early-exit discard path). The process security-environment contract is `HRESULT Create` + infallible by-value `Close` and consumes a PSEC 1.0 FlatBuffer, not the legacy SBOX buffer; generated PSEC bindings live in `core/generated/process_security_environment_specification`. -- `plm` (`host/plm`) is the Windows-only legacy WPR Learning Mode helper. Public `plm.exe` is `asInvoker`: ETL analysis and every caller-selected file path stay under the caller token. It self-elevates only hidden fixed WPR start/stop/cancel operations, authenticates the elevated child over unique local PID-checked named pipes, uses the compiled-in profile from protected fixed-volume ProgramData scratch, and streams bounded ETL bytes back to the unelevated parent. Guarded starts retain the elevated child through the workload; owner death or pipe break cancels the trace, while successful stop explicitly disarms the child before releasing the PLM singleton. +- `plm` (`host/plm`) is the Windows-only legacy WPR Learning Mode helper. Public `plm.exe` is `asInvoker`: ETL analysis and every caller-selected file path stay under the caller token. It self-elevates only the hidden fixed WPR start operation; the retained elevated guardian accepts authenticated attach and stop/discard controls over unique local PID-checked named pipes, uses the compiled-in profile from protected fixed-volume ProgramData scratch, and returns bounded analysis or trace bytes to the unelevated parent. Successful authenticated stop/discard disarms the child before releasing the PLM singleton. Owner death, pipe break, or another uncertain control failure preserves the recovery marker and deliberately leaves WPR untouched for administrator recovery. - `wxc`, `lxc`, and `mxc_darwin` are thin binary crates (`wxc-exec` / `lxc-exec` / `mxc-exec-mac`) that wire up CLI args (`clap`), load/validate config, handle maintenance modes (`--probe`, `--delete`, `--setup-*`, `--audit`), and **delegate all backend dispatch to `mxc_engine`**. They contain no `match request.containment` of their own. `wxc-exec` additionally owns the Windows Ctrl-C / DACL-cleanup / `--audit` PLM-trace / telemetry orchestration around the engine call. - `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request` / `build_request_with_containment`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. - `mxc-sdk` is the **public Rust SDK** — a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome`, captured `stdout`/`stderr`, warnings, and optional structured output metadata) or `spawn_sandbox(request)` (returns a `Sandbox` handle for live bidirectional stdio — `take_stdin`/`take_stdout`/`take_stderr`, `kill()`, `wait()` returning a `WaitOutcome` (`Exited(i32)` / `TimedOut`) as `io::Result`, `output_metadata()` after terminal completion, or `wait_with_output()`). It re-exports the engine's config-building surface (`build_request`, `build_request_with_containment` + `Containment`/`WslcSection`, `mxc_sdk::policy::{SandboxPolicy sections}`, discovery helpers) and `platform_support`; `mod sandbox` (wrapping the engine's `SandboxProcess` in `Sandbox`) is its only local module. No pty is ever allocated. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), Windows ProcessContainer (AppContainer + BaseContainer), and WSLC (Windows, experimental — needs the crate's `wslc` feature plus `SandboxRequest::set_experimental(true)`; no stdin and `id() == 0`, since the WSLC SDK exposes neither); other backends return `ErrorCode::UnsupportedContainment`. diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index f0940ad84..be6e5c665 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -127,34 +127,35 @@ Windows-only `captureDenials` config switch drives collecting those events and surfacing the resulting denials to the caller. Its `mode` selects how each ungranted access is handled while it is recorded: -> **Host requirement.** `captureDenials` requires a feature-enabled Windows +> **Host selection.** MXC prefers native capture on a feature-enabled Windows > build exposing the complete official V2 API set: > `StartLearningModeTrace`, `StopLearningModeTrace`, > `CloseLearningModeTrace`, `CreateProcessSecurityEnvironment`, > `QueryProcessSecurityEnvironmentSupport`, and -> `CloseProcessSecurityEnvironment`. It is not supported by the AppContainer -> fallback tiers; unsupported hosts return `backend_unavailable`. +> `CloseProcessSecurityEnvironment`. When that set is unavailable or cannot +> fully honor the requested policy, MXC retains the highest compatible legacy +> containment tier (SBOX, AppContainer+BFS, or AppContainer+DACL) and pairs it +> with the guarded WPR capture provider. Unsupported hosts return +> `backend_unavailable` only when neither path can preserve the full policy. > > Internal validation confirmed that build `26657.1002` exposes only the > incompatible earlier contract and is rejected, while build `26663.1000` > exposes the complete V2 contract. These are validation points, not a public > Windows release-floor commitment; callers should rely on the runtime probe. > -> `captureDenials` cannot be combined with `processContainer.leastPrivilege`; -> the Windows process security-environment API used for capture does not expose -> an LPAC token option, so MXC rejects that combination rather than silently -> weakening the requested policy. +> Native PSEC capture cannot represent `processContainer.leastPrivilege` +> because the process security-environment API does not expose an LPAC token +> option. MXC therefore retains a compatible legacy containment tier and uses +> guarded WPR instead of weakening or rejecting the requested policy. > -> `captureDenials` also cannot currently be combined with `network.proxy`. -> The V2 process security-environment proxy contract requires a separate proxy -> AppContainer peer identity; MXC rejects the combination until that peer is -> provisioned by the capture launch path. +> Native PSEC capture also cannot currently represent `network.proxy` without a +> separate proxy AppContainer peer identity. Compatible requests use guarded +> WPR with the legacy tier that can enforce the proxy contract. > -> `filesystem.deniedPaths` requires -> `QueryProcessSecurityEnvironmentSupport` to advertise -> `PSE_SUPPORT_FS_DENY`. When the bit is absent, capture fails as -> `backend_unavailable`; it cannot fall back to AppContainer or host-DACL -> enforcement. +> Native capture uses `filesystem.deniedPaths` only when +> `QueryProcessSecurityEnvironmentSupport` advertises `PSE_SUPPORT_FS_DENY`. +> Otherwise MXC selects a compatible legacy SBOX, AppContainer+BFS, or +> AppContainer+DACL tier and uses guarded WPR. - `mode: "block"` (default) maps onto `learningModeLogging` (deny-and-record) — the app / user-configurable flow. @@ -234,7 +235,9 @@ C# SDK exposes it through `RunResult.OutputMetadata` and By default, the intermediate ETW `.etl` trace is an internal, runner-managed file in a protected per-run temporary directory that MXC deletes after analysis. Set `captureDenials.retainEtl` to `true` to preserve the sealed trace -for diagnostics after a terminal wait. Retention-enabled captures begin under +for diagnostics after a terminal wait when native PSEC/V2 capture is selected. +Guarded-WPR fallback rejects `retainEtl: true` with `backend_unavailable` +rather than returning its raw host-wide trace. Retention-enabled captures begin under `%LOCALAPPDATA%\Microsoft\MXC\capture-denials\working` and move to a protected per-run directory under `capture-denials\retained` only after sealing succeeds. Abandoning or disposing a process without a terminal wait deletes the internal diff --git a/docs/process-container/os-version-support.md b/docs/process-container/os-version-support.md index 37e4f8a35..2fe6fd899 100644 --- a/docs/process-container/os-version-support.md +++ b/docs/process-container/os-version-support.md @@ -65,22 +65,42 @@ The PSEC probe requires: - `QueryProcessSecurityEnvironmentSupport` - `CloseProcessSecurityEnvironment` -When `processContainer.captureDenials` is present, fallback is not possible: -capture requires a PSEC handle to key the trace. The host must additionally -expose the complete official V2 Learning Mode export set: +When `processContainer.captureDenials` is present, MXC treats PSEC plus the +official V2 Learning Mode exports as one native capture capability set: - `StartLearningModeTrace` - `StopLearningModeTrace` - `CloseLearningModeTrace` -For capture, unsupported or earlier-contract hosts fail as -`backend_unavailable`. Ordinary ProcessContainer execution still follows the -fallback chain. Internal validation confirmed the earlier contract on build -`26657.1002` is rejected for capture while legacy SBOX execution remains -functional when PSEC is unavailable, and the full V2 contract on build -`26663.1000` is accepted. These -builds are validation points, not a public release-floor commitment; runtime -probing is the source of truth. +When that complete set is available, MXC uses PSEC with native V2 capture. +Otherwise it retains the highest legacy containment tier that can fully honor +the request (SBOX, AppContainer+BFS, or AppContainer+DACL) and pairs it with +the guarded WPR capture provider. The elevated guardian filters the host-wide +trace to OS-observed process lifetime windows: before the suspended sandbox +child resumes, the authenticated owner sends its job and still-owned root +process HANDLE values. The guardian duplicates both from that authenticated +process, verifies the duplicated process belongs to the duplicated job, and +retains the stable process handle. The root generation uses exact kernel +creation/exit FILETIMEs read from that handle (with the exit time read only +after WPR stops). For every descendant new-process notification, the guardian +opens and retains a process handle, verifies membership in the duplicated job, +and reads exact creation/exit FILETIMEs. Denial filtering uses those +handle-attested lifetimes directly; it does not infer process generations from +host-wide ETL lifecycle timestamps. At finish, job accounting +`TotalProcesses` must equal the retained unique root-plus-descendant +generations, so missing or inconsistent membership notifications fail closed. +Guarded capture tracks at most 4096 root-plus-descendant process generations +per execution. Exceeding that bound fails capture teardown and emits no denial +output rather than continuing with an incomplete process scope. +The owner never supplies PID/time scopes. Only bounded canonical denial data +returns; raw ETL does not cross into the SDK result. If no containment tier can +honor the policy, or the guarded PLM helper is unavailable, the request fails +as `backend_unavailable`. + +Internal validation confirmed the earlier contract on build `26657.1002` uses +legacy containment rather than native capture, while the full V2 contract on +build `26663.1000` is accepted. These builds are validation points, not a +public release-floor commitment; runtime probing is the source of truth. The PSEC contract cannot represent `processContainer.leastPrivilege`, so requests using that option use the transitional SBOX contract instead of diff --git a/docs/schema.md b/docs/schema.md index e60854a75..d81f5ba78 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -84,10 +84,13 @@ production configs and the dev schema when working on experimental features: // path in output metadata. Defaults to false. // Retention requires a terminal wait; abandoning the // process handle deletes the internal trace. + // Requires native PSEC/V2 capture; guarded-WPR fallback + // rejects retention rather than exposing a host-wide ETL. } // Omit outputPath for a managed JSON output file. - // captureDenials cannot be combined with leastPrivilege. - // captureDenials cannot currently be combined with network.proxy. + // Native PSEC/V2 capture cannot combine with leastPrivilege + // or network.proxy. Hosts without that complete native set + // retain an eligible legacy containment tier and use guarded WPR. }, "lxc": { // LXC-specific diff --git a/schemas/dev/mxc-config.schema.0.8.0-dev.json b/schemas/dev/mxc-config.schema.0.8.0-dev.json index e93dd58f4..d4d99bb99 100644 --- a/schemas/dev/mxc-config.schema.0.8.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.8.0-dev.json @@ -46,7 +46,7 @@ }, "CaptureDenials": { "additionalProperties": false, - "description": "Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Capture is incompatible with `processContainer.leastPrivilege` and `network.proxy`. Explicit `filesystem.deniedPaths` requires the host's V2 process security-environment support query to advertise native deny enforcement.", + "description": "Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Native capture requires the complete compatible PSEC plus V2 Learning Mode API set. Requests that native capture cannot represent use guarded WPR with a compatible legacy SBOX or AppContainer containment tier.", "properties": { "mode": { "anyOf": [ @@ -608,7 +608,7 @@ "type": "null" } ], - "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability." + "description": "Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. MXC prefers native PSEC plus V2 Learning Mode when that API set can fully honor the request. Otherwise it retains the highest compatible legacy containment tier and uses guarded WPR capture, so `leastPrivilege`, `network.proxy`, and deny-path policies can remain enforced without weakening the request." }, "learningMode": { "description": "AppContainer learning mode (deny-and-record): failed access checks are logged for diagnostics while the accesses stay denied; containment is unchanged. Distinct from the allow-all `permissiveLearningMode` capability, which is injected internally by the `--audit` CLI flag or dedicated denial-capture configuration.", diff --git a/sdk/node/src/generated/wire.ts b/sdk/node/src/generated/wire.ts index 6289fed78..08363feef 100644 --- a/sdk/node/src/generated/wire.ts +++ b/sdk/node/src/generated/wire.ts @@ -38,7 +38,7 @@ export interface BaseProcessUi { } /** - * Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Capture is incompatible with `processContainer.leastPrivilege` and `network.proxy`. Explicit `filesystem.deniedPaths` requires the host's V2 process security-environment support query to advertise native deny enforcement. + * Windows denial-capture settings. The presence of the `captureDenials` object enables capture; all fields are optional. Native capture requires the complete compatible PSEC plus V2 Learning Mode API set. Requests that native capture cannot represent use guarded WPR with a compatible legacy SBOX or AppContainer containment tier. */ export interface CaptureDenials { /** @@ -281,7 +281,7 @@ export interface ProcessContainer { */ capabilities?: string[] | null; /** - * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. Requires a host that exposes the complete official V2 Learning Mode and process security-environment API set. Cannot be combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` additionally requires the V2 deny-support capability. + * Windows denial capture. When present, the runner records the sandboxed process's access attempts to a learning-mode ETL trace for later inspection. MXC prefers native PSEC plus V2 Learning Mode when that API set can fully honor the request. Otherwise it retains the highest compatible legacy containment tier and uses guarded WPR capture, so `leastPrivilege`, `network.proxy`, and deny-path policies can remain enforced without weakening the request. */ captureDenials?: CaptureDenials | null; /** diff --git a/src/Cargo.lock b/src/Cargo.lock index cb387bca0..349838cc0 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1529,11 +1529,14 @@ dependencies = [ "hyperlight_common", "isolation_session_bindings", "isolation_session_common", + "learning_mode_core", "lxc_common", "nanvix_runner", + "plm", "seatbelt_common", "serde", "serde_json", + "windows", "windows_sandbox_lifecycle", "wslc_common", "wxc_common", diff --git a/src/Cargo.toml b/src/Cargo.toml index d539fe526..85f0e8bea 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -86,6 +86,7 @@ mxc_telemetry = { path = "mxc_telemetry" } nanvix_runner = { path = "backends/nanvix/runner" } nix = { version = "0.29", features = ["fs", "mount", "sched", "signal", "net", "process", "user", "term"] } process_security_environment_spec = { path = "core/generated/process_security_environment_specification" } +plm = { path = "host/plm" } quick-xml = "0.41" sandbox_spec = { path = "core/generated/base_container_specification" } seatbelt_common = { path = "backends/seatbelt/common" } diff --git a/src/backends/appcontainer/common/src/appcontainer_runner.rs b/src/backends/appcontainer/common/src/appcontainer_runner.rs index 428c6aa3f..d2c435674 100644 --- a/src/backends/appcontainer/common/src/appcontainer_runner.rs +++ b/src/backends/appcontainer/common/src/appcontainer_runner.rs @@ -3,6 +3,7 @@ use std::io::IsTerminal; use std::ptr; +use std::sync::Arc; use windows::Win32::Foundation::{ CloseHandle, GetLastError, LocalFree, SetHandleInformation, ERROR_ALREADY_EXISTS, HANDLE, @@ -30,12 +31,15 @@ use windows::Win32::System::Threading::{ }; use windows_core::{PCWSTR, PWSTR}; +use crate::capture_output; +use crate::guarded_capture::{GuardedCaptureFactory, GuardedCaptureSession}; use crate::job_object::UiJobObject; use crate::process_mitigation; use wxc_common::error::WxcError; use wxc_common::logger::Logger; use wxc_common::models::{ - ExecutionRequest, FailurePhase, NetworkEnforcementMode, NetworkPolicy, ScriptResponse, + ExecutionRequest, FailurePhase, NetworkEnforcementMode, NetworkPolicy, SandboxOutputMetadata, + ScriptResponse, }; use wxc_common::process_util::{ create_std_pipes, InterruptiblePipeReader, OwnedHandle, PipeReadCanceller, PipeWriter, @@ -49,7 +53,9 @@ use wxc_common::script_runner::get_timeout_milliseconds; use wxc_common::{string_util, ui_policy}; pub(crate) const CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG: &str = - "captureDenials requires the BaseContainer learning-mode APIs and is not supported by the AppContainer fallback tier"; + "captureDenials requires either the native BaseContainer learning-mode APIs or an \ + AppContainer runner explicitly configured with the guarded-WPR capture fallback \ + (see AppContainerScriptRunner::with_guarded_capture_factory)"; /// `UpdateProcThreadAttribute` value for /// `PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY` that opts the @@ -518,6 +524,7 @@ pub struct AppContainerScriptRunner { app_container_sid: PSID, proxy_address: Option, filesystem_mode: FilesystemMode, + denied_paths_enforced_externally: bool, /// Optional pre-derived SID string supplied by the dispatcher. /// /// When `Some`, the runner uses this value for the firewall @@ -529,6 +536,16 @@ pub struct AppContainerScriptRunner { /// [`DeriveAppContainerSidFromAppContainerName`] / `FreeSid`; that /// duplicate Win32 call is documented and left as a follow-up. preset_sid_string: Option, + /// Opts this runner into the guarded-WPR `captureDenials` fallback. + /// + /// `None` (the default for every plain constructor) means the + /// runner behaves exactly as it always has: `captureDenials` is + /// rejected in [`SandboxBackend::validate`]. Only a caller that + /// explicitly chains [`Self::with_guarded_capture_factory`] (the + /// dispatcher, when it selects a legacy tier for a request that + /// asked for `captureDenials`) opts a runner into accepting it — see + /// the module-level fallback docs on [`crate::guarded_capture`]. + guarded_capture_factory: Option>, } impl AppContainerScriptRunner { @@ -538,7 +555,9 @@ impl AppContainerScriptRunner { app_container_sid: PSID(ptr::null_mut()), proxy_address: None, filesystem_mode: FilesystemMode::Bfs, + denied_paths_enforced_externally: false, preset_sid_string: None, + guarded_capture_factory: None, } } @@ -552,7 +571,9 @@ impl AppContainerScriptRunner { app_container_sid: PSID(ptr::null_mut()), proxy_address: None, filesystem_mode: mode, + denied_paths_enforced_externally: false, preset_sid_string: None, + guarded_capture_factory: None, } } @@ -570,10 +591,35 @@ impl AppContainerScriptRunner { app_container_sid: PSID(ptr::null_mut()), proxy_address: None, filesystem_mode: mode, + denied_paths_enforced_externally: false, preset_sid_string: Some(sid_string), + guarded_capture_factory: None, } } + /// Opts this runner into the guarded-WPR `captureDenials` legacy-tier + /// fallback, using `factory` to start an elevated, process-scoped WPR + /// capture for each run. + /// + /// Must be called explicitly by the caller that selects this runner for + /// a `captureDenials` request (the dispatcher, when the native + /// BaseContainer tier is unavailable) — an `AppContainerScriptRunner` + /// constructed any other way keeps rejecting `captureDenials` in + /// [`SandboxBackend::validate`]. See [`crate::guarded_capture`] for the + /// full rationale: `appcontainer_common` never depends on `plm` + /// directly, so `factory` is a trait object implemented by a higher + /// layer (`mxc_engine`) that does. + pub fn with_guarded_capture_factory(mut self, factory: Arc) -> Self { + self.guarded_capture_factory = Some(factory); + self + } + + /// Marks `deniedPaths` as enforced by the dispatcher's per-run DACL guard. + pub(crate) fn with_external_denied_paths(mut self) -> Self { + self.denied_paths_enforced_externally = true; + self + } + /// Create or derive an AppContainer SID for the given container name. /// /// Returns a [`PSID`] owned by the runner (released via [`FreeSid`] in @@ -1061,6 +1107,81 @@ impl AppContainerScriptRunner { } }; + // --- Guarded WPR captureDenials session (legacy-tier fallback) --- + // + // Started only now — after the job has been created, UI-limited, + // and the still-suspended child assigned to it — but before the + // caller resumes the child (`SpawnedChild::resume`, called by + // `SandboxBackend::spawn` right after this function returns). That + // ordering means a guardian-start failure can terminate the + // still-suspended child without ever leaving an active host-wide + // WPR trace running. + let (capture_session, capture_output_path): ( + Option>, + Option, + ) = match ( + self.guarded_capture_factory.as_ref(), + request.policy.capture_denials.as_ref(), + ) { + (Some(factory), Some(capture_config)) => { + let output_path = match capture_output::unique_denials_output_path( + capture_config.output_path.as_deref(), + ) { + Ok(path) => path, + Err(e) => { + job.terminate_and_wait(u32::MAX) + .map_err(|terminate_error| { + WxcError::Process(format!( + "captureDenials failed to resolve the denials output path: {e}; \ + additionally failed to terminate the suspended sandbox: \ + {terminate_error}" + )) + })?; + return Err(WxcError::Process(format!( + "captureDenials failed to resolve the denials output path: {e}" + ))); + } + }; + match factory.start(std::process::id()) { + Ok(session) => { + let session = attach_guarded_capture_or_cleanup( + session, + job.handle_value(), + process_handle.get().0 as usize, + || { + job.terminate_and_wait(u32::MAX) + .err() + .map(|error| error.to_string()) + }, + ) + .map_err(WxcError::Process)?; + logger.log_line(&format!( + "guarded WPR captureDenials session started (output: {})", + output_path.display() + )); + (Some(session), Some(output_path)) + } + Err(e) => { + // No active trace exists yet -- terminate the + // still-suspended child now, before it is ever + // resumed, so nothing runs unobserved. + job.terminate_and_wait(u32::MAX) + .map_err(|terminate_error| { + WxcError::Process(format!( + "captureDenials guarded WPR session failed to start: {e}; \ + additionally failed to terminate the suspended sandbox: \ + {terminate_error}" + )) + })?; + return Err(WxcError::Process(format!( + "captureDenials guarded WPR session failed to start: {e}" + ))); + } + } + } + _ => (None, None), + }; + let (stdout_read, stderr_read) = match capture_reads { Some((out, err)) => (Some(out), Some(err)), None => (None, None), @@ -1073,6 +1194,8 @@ impl AppContainerScriptRunner { thread: thread_handle, job, pid: pi.dwProcessId, + capture_session, + capture_output_path, stdin_write: captured_stdin_write, stdout_read, stderr_read, @@ -1118,6 +1241,15 @@ struct SpawnedChild { job: UiJobObject, /// OS process id of the child. pid: u32, + /// Live guarded WPR capture session for `captureDenials`, started + /// (while still suspended) by [`AppContainerScriptRunner::spawn_suspended`]. + /// `Some` only when the runner was configured via + /// [`AppContainerScriptRunner::with_guarded_capture_factory`] and the + /// request asked for `captureDenials`. + capture_session: Option>, + /// Resolved JSON denials deliverable path. `Some` iff `capture_session` + /// is `Some`. + capture_output_path: Option, /// Parent's stdin write-end (Some only when spawned for streaming). stdin_write: Option, /// Parent's stdout/stderr read-ends (Some only in streaming mode). @@ -1128,17 +1260,76 @@ struct SpawnedChild { impl SpawnedChild { /// Resume the suspended child, terminating it on failure. - fn resume(&self) -> Result<(), WxcError> { + /// + /// If a guarded WPR capture was started for this child, a resume + /// failure also discards it (see + /// [`Self::discard_capture_session_after_launch_failure`]) before + /// returning the resume error, so the elevated guardian is never left + /// tracing a child that never ran. + fn resume(&mut self) -> Result<(), WxcError> { let r = unsafe { ResumeThread(self.thread.get()) }; if r == u32::MAX { let err = unsafe { GetLastError() }; unsafe { let _ = TerminateProcess(self.process.get(), u32::MAX); + let _ = WaitForSingleObject(self.process.get(), u32::MAX); } + self.discard_capture_session_after_launch_failure(); return Err(WxcError::Process(format!("ResumeThread failed: {:?}", err))); } Ok(()) } + + /// Best-effort teardown of an armed-but-abandoned guarded WPR capture + /// session, used when the sandboxed child failed to launch after the + /// session was already started. + /// + /// The child never ran successfully, so there is no analysis result to + /// preserve. Stop the trace through the authenticated discard protocol. + fn discard_capture_session_after_launch_failure(&mut self) { + let Some(mut session) = self.capture_session.take() else { + return; + }; + let _ = session.discard(); + } +} + +/// Attach `session` to the sandbox job / root process, or perform the +/// security-sensitive abandonment cleanup on failure. +/// +/// On attach failure the job is terminated (via `terminate_job`, which returns +/// `Some(message)` if termination failed) **before** the guarded session is +/// discarded — nothing may keep running once the trace is about to be torn +/// down — and the returned error reports the attach failure plus any +/// termination/discard failures, in that order. Returns the live session on +/// success. Extracted so the orchestration ordering can be unit-tested with a +/// fake session and a fake job terminator, without a real suspended process. +fn attach_guarded_capture_or_cleanup( + mut session: Box, + job_handle: usize, + root_process_handle: usize, + terminate_job: impl FnOnce() -> Option, +) -> Result, String> { + let Err(attach_error) = session.attach_process_tree(job_handle, root_process_handle) else { + return Ok(session); + }; + let termination_error = terminate_job(); + let discard_error = session.discard().err(); + let mut message = format!( + "captureDenials guarded WPR session failed to attach the sandbox process tree: \ + {attach_error}" + ); + if let Some(terminate_error) = termination_error { + message.push_str(&format!( + "; additionally failed to terminate the suspended sandbox: {terminate_error}" + )); + } + if let Some(discard_error) = discard_error { + message.push_str(&format!( + "; additionally failed to stop and discard guarded WPR: {discard_error}" + )); + } + Err(message) } impl Default for AppContainerScriptRunner { @@ -1304,13 +1495,27 @@ impl AppContainerScriptRunner { impl SandboxBackend for AppContainerScriptRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { - if request.policy.capture_denials.is_some() { + if request + .policy + .capture_denials + .as_ref() + .is_some_and(|config| config.retain_etl) + { + return Err(ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(crate::guarded_capture::RETAIN_ETL_UNSUPPORTED_MSG) + }); + } + if request.policy.capture_denials.is_some() && self.guarded_capture_factory.is_none() { return Err(ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, ..ScriptResponse::error(CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG) }); } - if !request.policy.denied_paths.is_empty() && self.filesystem_mode != FilesystemMode::Dacl { + if !request.policy.denied_paths.is_empty() + && self.filesystem_mode != FilesystemMode::Dacl + && !self.denied_paths_enforced_externally + { return Err(ScriptResponse::error( wxc_common::error::DENIED_PATHS_NOT_SUPPORTED_MSG, )); @@ -1339,7 +1544,7 @@ impl SandboxBackend for AppContainerScriptRunner { // Pipes → capture pipes the caller drives; Inherit → the child inherits // the binary's own std handles / console (a TTY when the binary has one). let capture = stdio == StdioMode::Pipes; - let child = match self.spawn_suspended(request, logger, capture) { + let mut child = match self.spawn_suspended(request, logger, capture) { Ok(c) => c, Err(e) => { self.teardown(&mut prepared, request.lifecycle.preserve_policy, logger); @@ -1389,7 +1594,19 @@ struct AppContainerSandboxProcess { filesystem_mode: FilesystemMode, preserve_policy: bool, timeout_ms: u32, - teardown_done: bool, + teardown_result: Option>, + /// Live guarded WPR capture session, moved from the `SpawnedChild`. + /// Stopped and analyzed in `run_teardown` once the child has exited and + /// been reaped. + capture_session: Option>, + /// Resolved JSON denials deliverable path. `Some` iff `capture_session` + /// is `Some`. + capture_output_path: Option, + /// Exit code of the child, recorded by `wait` before teardown so the + /// denials summary can carry it. `None` on the `Drop`/early-exit path. + last_exit_code: Option, + /// Structured output published after capture teardown succeeds. + output_metadata: Option, } // SAFETY: the fields are Windows HANDLEs / handle-owning managers and owned @@ -1438,15 +1655,18 @@ impl AppContainerSandboxProcess { filesystem_mode, preserve_policy: request.lifecycle.preserve_policy, timeout_ms: child.timeout_ms, - teardown_done: false, + teardown_result: None, + capture_session: child.capture_session.take(), + capture_output_path: child.capture_output_path.take(), + last_exit_code: None, + output_metadata: None, } } - fn run_teardown(&mut self) { - if self.teardown_done { - return; + fn run_teardown(&mut self) -> std::io::Result<()> { + if let Some(result) = &self.teardown_result { + return result.clone().map_err(std::io::Error::other); } - self.teardown_done = true; let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); self.prepared .network_manager @@ -1457,10 +1677,68 @@ impl AppContainerSandboxProcess { { self.prepared.bfs_manager.remove_configuration(&mut logger); } + + // Stop and analyze the guarded WPR capture now that the child has + // exited and been reaped (both `wait` and `Drop` kill + reap before + // calling this). `stop_analyzed` returns only the bounded, + // process-scoped `AnalysisResult` -- the raw host-wide ETL never + // crosses back into this process. Write it through the same shared + // `capture_output` plumbing the native BaseContainer path uses, so + // the two paths emit byte-identical JSON. + let result: std::io::Result<()> = if let Some(mut session) = self.capture_session.take() { + let output_path = self.capture_output_path.take(); + let exit_code = self.last_exit_code.unwrap_or(-1); + let capture_result = match session.stop_analyzed() { + Ok(analysis) => match output_path { + Some(output_path) => { + capture_output::write_denials_document(analysis, exit_code, &output_path) + } + None => Err(std::io::Error::other( + "captureDenials internal output path was not initialized", + )), + }, + Err(error) => Err(std::io::Error::other(format!( + "captureDenials failed to stop and analyze the guarded WPR session: {error}" + ))), + }; + match capture_result { + Ok(metadata) => { + self.output_metadata = Some(SandboxOutputMetadata { + capture_denials: Some(metadata), + capture_denials_error: None, + }); + Ok(()) + } + Err(error) => Err(error), + } + } else { + Ok(()) + }; + let result = result.map_err(|error| error.to_string()); + self.teardown_result = Some(result.clone()); + result.map_err(std::io::Error::other) + } + + fn release_guarded_capture_after_termination_failure(&mut self) { + let Some(session) = self.capture_session.take() else { + return; + }; + // The trait contract keeps this call blocked until the elevated + // guardian has released its duplicate job handle, even when discard + // itself fails. Only then may Drop return and release enforcement. + if let Err(error) = crate::guarded_capture::release_after_termination_failure(session) { + capture_output::write_stderr_line_best_effort(format_args!( + "failed to discard guarded WPR capture after sandbox termination failure: {error}" + )); + } } } impl SandboxProcess for AppContainerSandboxProcess { + fn output_metadata(&self) -> Option<&SandboxOutputMetadata> { + self.output_metadata.as_ref() + } + fn take_stdin(&mut self) -> Option> { take_boxed_write(&mut self.stdin) } @@ -1502,8 +1780,27 @@ impl SandboxProcess for AppContainerSandboxProcess { fn kill(&mut self) -> std::io::Result<()> { // Terminate the whole job: the child and every descendant assigned to // it die together (tree-kill). - self.job.terminate(u32::MAX); - Ok(()) + if self.capture_session.is_some() { + // Guarded-WPR capture needs strict drain certainty before the trace + // is stopped/discarded; a failure to drain is a hard error here. + return self + .job + .terminate_and_wait(u32::MAX) + .map_err(|error| std::io::Error::other(error.to_string())); + } + // Ordinary run: terminate the tree, but downgrade a slow drain to a + // warning so it does not fail an otherwise valid result. + match self.job.terminate_best_effort(u32::MAX) { + Ok(Some(drain_warning)) => { + capture_output::write_stderr_line_best_effort(format_args!( + "sandbox job did not fully drain within the teardown window (continuing): \ + {drain_warning}" + )); + Ok(()) + } + Ok(None) => Ok(()), + Err(error) => Err(std::io::Error::other(error.to_string())), + } } fn wait(&mut self) -> std::io::Result { @@ -1541,14 +1838,20 @@ impl SandboxProcess for AppContainerSandboxProcess { // (immediate once it has exited) before releasing the pipe drains — and // killing the tree closes the descendant's pipe write-ends, so the drains // can finish. - let _ = self.kill(); - unsafe { - let _ = WaitForSingleObject(self.process.get(), u32::MAX); + let termination_result = self.kill(); + if termination_result.is_ok() { + unsafe { + let _ = WaitForSingleObject(self.process.get(), u32::MAX); + } } cancel_and_join_discard(stdout_thread, &self.stdout_canceller); cancel_and_join_discard(stderr_thread, &self.stderr_canceller); - self.run_teardown(); - result + termination_result?; + // Record the child's exit code so `run_teardown` can stamp it into the + // denials summary. On a timeout / wait failure there is no exit code. + self.last_exit_code = result.as_ref().ok().copied(); + let teardown_result = self.run_teardown(); + capture_output::combine_process_and_teardown_results(result, teardown_result) } } @@ -1557,11 +1860,21 @@ impl Drop for AppContainerSandboxProcess { // Kill the tree and reap before tearing down firewall/filesystem // policy, so an abandoned-but-running sandbox cannot outlive its // enforcement (or leak as an orphan). `kill()` terminates the job. - let _ = self.kill(); + if let Err(error) = self.kill() { + capture_output::write_stderr_line_best_effort(format_args!( + "failed to terminate sandbox job during drop: {error}" + )); + self.release_guarded_capture_after_termination_failure(); + return; + } unsafe { let _ = WaitForSingleObject(self.process.get(), u32::MAX); } - self.run_teardown(); + if let Err(error) = self.run_teardown() { + capture_output::write_stderr_line_best_effort(format_args!( + "captureDenials teardown failed during drop: {error}" + )); + } } } @@ -1833,9 +2146,40 @@ mod tests { use super::{ AppContainerScriptRunner, FilesystemMode, CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG, }; + use crate::guarded_capture::{GuardedCaptureFactory, GuardedCaptureSession}; + use learning_mode_core::AnalysisResult; + use std::sync::Arc; use wxc_common::models::{ExecutionRequest, FailurePhase}; use wxc_common::sandbox_process::SandboxBackend; + /// A fake [`GuardedCaptureFactory`] used only to exercise `validate()`'s + /// factory-presence gate — its `start` is never called by these tests. + struct FakeGuardedCaptureFactory; + + impl GuardedCaptureFactory for FakeGuardedCaptureFactory { + fn start(&self, _owner_pid: u32) -> Result, String> { + struct FakeSession; + impl GuardedCaptureSession for FakeSession { + fn attach_process_tree( + &mut self, + _job_handle: usize, + _root_process_handle: usize, + ) -> Result<(), String> { + Ok(()) + } + + fn discard(&mut self) -> Result<(), String> { + Ok(()) + } + + fn stop_analyzed(&mut self) -> Result { + Ok(AnalysisResult::complete(Vec::new())) + } + } + Ok(Box::new(FakeSession)) + } + } + #[test] fn validate_runner_rejects_denied_paths_in_bfs_mode() { let runner = AppContainerScriptRunner::with_filesystem_mode(FilesystemMode::Bfs); @@ -1864,6 +2208,150 @@ mod tests { ); } + #[test] + fn validate_runner_accepts_dispatcher_enforced_denied_paths_in_bfs_mode() { + let runner = AppContainerScriptRunner::with_filesystem_mode(FilesystemMode::Bfs) + .with_external_denied_paths(); + let mut request = ExecutionRequest::default(); + request.policy.denied_paths = vec!["C:\\secret".into()]; + + assert!(runner.validate(&request).is_ok()); + } + + /// Records the order of guarded-capture callbacks so orchestration tests can + /// assert the security-sensitive teardown ordering explicitly. + struct RecordingSession { + events: Arc>>, + attach_result: Result<(), String>, + discard_result: Result<(), String>, + } + + impl GuardedCaptureSession for RecordingSession { + fn attach_process_tree( + &mut self, + _job_handle: usize, + _root_process_handle: usize, + ) -> Result<(), String> { + self.events.lock().unwrap().push("attach".to_string()); + self.attach_result.clone() + } + + fn discard(&mut self) -> Result<(), String> { + self.events.lock().unwrap().push("discard".to_string()); + self.discard_result.clone() + } + + fn stop_analyzed(&mut self) -> Result { + self.events + .lock() + .unwrap() + .push("stop_analyzed".to_string()); + Ok(AnalysisResult::complete(Vec::new())) + } + } + + #[test] + fn attach_failure_terminates_job_before_discarding_and_composes_message() { + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let session = Box::new(RecordingSession { + events: Arc::clone(&events), + attach_result: Err("attach boom".to_string()), + discard_result: Err("discard boom".to_string()), + }); + let events_for_kill = Arc::clone(&events); + + let error = match super::attach_guarded_capture_or_cleanup(session, 1, 2, || { + events_for_kill + .lock() + .unwrap() + .push("terminate".to_string()); + Some("terminate boom".to_string()) + }) { + Ok(_) => panic!("attach failure must abandon the launch"), + Err(error) => error, + }; + + // Ordering is load-bearing: attach is attempted, then the job is + // terminated, and only then is the session discarded — nothing may keep + // running once the trace is about to be torn down. + assert_eq!( + *events.lock().unwrap(), + vec![ + "attach".to_string(), + "terminate".to_string(), + "discard".to_string() + ] + ); + // The composed message reports all three failures, terminate before + // discard. + assert!(error.contains("attach boom"), "got: {error}"); + let terminate_at = error.find("terminate boom").expect("terminate reported"); + let discard_at = error.find("discard boom").expect("discard reported"); + assert!( + terminate_at < discard_at, + "terminate must be reported before discard: {error}" + ); + } + + #[test] + fn attach_failure_reports_only_attach_when_cleanup_succeeds() { + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let session = Box::new(RecordingSession { + events: Arc::clone(&events), + attach_result: Err("attach boom".to_string()), + discard_result: Ok(()), + }); + + let error = match super::attach_guarded_capture_or_cleanup(session, 1, 2, || None) { + Ok(_) => panic!("attach failure must abandon the launch"), + Err(error) => error, + }; + + assert!(error.contains("attach boom"), "got: {error}"); + assert!( + !error.contains("additionally"), + "clean cleanup must not append failure suffixes: {error}" + ); + assert_eq!( + *events.lock().unwrap(), + vec!["attach".to_string(), "discard".to_string()] + ); + } + + #[test] + fn attach_success_returns_the_live_session_without_cleanup() { + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let session = Box::new(RecordingSession { + events: Arc::clone(&events), + attach_result: Ok(()), + discard_result: Ok(()), + }); + + let session = super::attach_guarded_capture_or_cleanup(session, 1, 2, || { + panic!("the job must not be terminated when attach succeeds") + }) + .expect("attach success returns the live session"); + + // The live session is returned undisturbed (no discard on success). + assert_eq!(*events.lock().unwrap(), vec!["attach".to_string()]); + drop(session); + } + + #[test] + fn discard_after_launch_failure_stops_the_trace_once() { + // The resume-failure path abandons an already-started capture via + // `discard`. Model that discard contract directly: it must be invoked + // exactly once to stop the trace, with no analysis attempted. + let events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let mut session: Box = Box::new(RecordingSession { + events: Arc::clone(&events), + attach_result: Ok(()), + discard_result: Ok(()), + }); + let _ = session.discard(); + assert_eq!(*events.lock().unwrap(), vec!["discard".to_string()]); + } + #[test] fn validate_runner_rejects_allowed_hosts() { let runner = AppContainerScriptRunner::new(); @@ -1910,4 +2398,39 @@ mod tests { CAPTURE_DENIALS_FALLBACK_UNSUPPORTED_MSG ); } + + #[test] + fn validate_runner_accepts_capture_denials_with_guarded_factory() { + let runner = AppContainerScriptRunner::new() + .with_guarded_capture_factory(Arc::new(FakeGuardedCaptureFactory)); + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + + assert!( + runner.validate(&request).is_ok(), + "a runner explicitly configured with a guarded capture factory must accept \ + captureDenials" + ); + } + + #[test] + fn validate_runner_rejects_etl_retention_with_guarded_capture() { + let runner = AppContainerScriptRunner::new() + .with_guarded_capture_factory(Arc::new(FakeGuardedCaptureFactory)); + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(wxc_common::models::CaptureDenialsConfig { + retain_etl: true, + ..Default::default() + }); + + let error = runner + .validate(&request) + .expect_err("guarded capture must not expose its host-wide ETL"); + + assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + error.error_message, + crate::guarded_capture::RETAIN_ETL_UNSUPPORTED_MSG + ); + } } diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 240b4497e..67f9f4d69 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -16,9 +16,7 @@ use std::path::{Path, PathBuf}; use std::ptr; use std::sync::Arc; -use learning_mode_core::{ - write_document, DenialAnalyzer, DenialSummary, DenialsDocument, DenialsOutputPointer, -}; +use learning_mode_core::DenialAnalyzer; use learning_mode_windows::{ CaptureSession, EtlDenialAnalyzer, LearningModeApi, ProcessSecurityEnvironment, SecurityEnvironmentApi, SecurityEnvironmentStartupInfo, PROCESS_SECURITY_ENVIRONMENT_FLAG_NONE, @@ -40,6 +38,12 @@ use windows::Win32::System::Threading::{ }; use windows_core::{PCWSTR, PWSTR}; +use crate::capture_output::{ + combine_capture_and_cleanup_results, combine_process_and_teardown_results, + remove_internal_capture_file, unique_denials_output_path, write_denials_document, + write_stderr_line_best_effort, +}; +use crate::guarded_capture::{GuardedCaptureFactory, GuardedCaptureSession}; use crate::job_object::UiJobObject; use crate::launch_diagnostics::{ diagnose_create_process_failure, diagnose_environment_not_supported, diagnose_process_exit, @@ -385,6 +389,7 @@ pub struct BaseContainerRunner { proxy_coordinator: ProxyCoordinator, capture_factory: Arc, capture_support: Arc, + guarded_capture_factory: Option>, #[cfg(test)] psec_usable_override: Option, } @@ -395,6 +400,7 @@ impl Default for BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory: Arc::new(RealCaptureSessionFactory), capture_support: Arc::new(RealCapturePlatformSupport), + guarded_capture_factory: None, #[cfg(test)] psec_usable_override: None, } @@ -421,6 +427,13 @@ fn run_sandbox_cleanup( ); } +fn guarded_capture_started_too_late( + previous_suspend_count: u32, + guarded_capture_active: bool, +) -> bool { + guarded_capture_active && previous_suspend_count == 0 +} + impl BaseContainerRunner { pub fn new() -> Self { Self::default() @@ -432,6 +445,7 @@ impl BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory, capture_support: Arc::new(RealCapturePlatformSupport), + guarded_capture_factory: None, psec_usable_override: Some(true), } } @@ -445,16 +459,81 @@ impl BaseContainerRunner { proxy_coordinator: ProxyCoordinator::default(), capture_factory, capture_support, + guarded_capture_factory: None, psec_usable_override: Some(true), } } + pub fn with_guarded_capture_factory(mut self, factory: Arc) -> Self { + self.guarded_capture_factory = Some(factory); + self + } + fn cleanup_capture_begin_failure(&mut self, logger: &mut Logger) { // CaptureSession owns and closes the PSEC environment. No legacy // identity/tracking state is created for this path. self.proxy_coordinator.stop(logger); } + /// Security-sensitive teardown shared by every guarded-capture failure path + /// that must abandon a sandbox before it is handed to the caller (attach + /// failure, guardian-start failure, and post-resume failure). + /// + /// Ordering is load-bearing: the job is terminated **first** (killing the + /// child and every descendant), and per-run sandbox enforcement plus the + /// proxy are torn down **only** once termination succeeded — never while a + /// process could still be running unobserved. A guarded WPR `session`, if + /// one was already started, is discarded through the authenticated + /// protocol. Returns `base_message` with any termination/discard failures + /// appended; `terminate_context` names what was being terminated (e.g. "the + /// suspended sandbox" vs "the sandbox process tree"). + #[allow(clippy::too_many_arguments)] + fn abandon_capture_launch( + &mut self, + job: &UiJobObject, + process: HANDLE, + thread: HANDLE, + session: Option>, + identity: &str, + sid_string: &str, + legacy_destroy_on_exit: bool, + proxy_enabled: bool, + terminate_context: &str, + mut base_message: String, + logger: &mut Logger, + ) -> String { + // Guarded capture needs strict drain certainty here: nothing may be + // left running before the trace is stopped/discarded. + let termination_error = job.terminate_and_wait(u32::MAX).err(); + let discard_error = session.and_then(|mut session| session.discard().err()); + // SAFETY: `process`/`thread` are the just-created, still-owned child + // handles; nothing else references them on this failure path. + unsafe { + let _ = CloseHandle(process); + let _ = CloseHandle(thread); + } + if termination_error.is_none() { + if legacy_destroy_on_exit { + run_sandbox_cleanup(identity, sid_string, proxy_enabled, logger); + sandbox_tracking::unregister_ctrl_c_cleanup(); + } + self.proxy_coordinator.stop(logger); + } + if let Some(terminate_error) = termination_error { + let _ = write!( + base_message, + "; additionally failed to terminate {terminate_context}: {terminate_error}" + ); + } + if let Some(discard_error) = discard_error { + let _ = write!( + base_message, + "; additionally failed to stop and discard guarded WPR: {discard_error}" + ); + } + base_message + } + /// Pre-flight probe: check whether the current OS build exports the /// `Experimental_CreateProcessInSandbox` symbol from `processmodel.dll`. /// @@ -810,9 +889,10 @@ impl BaseContainerRunner { if !psec_usable { return false; } - if request.policy.capture_denials.is_some() { - return true; - } + Self::psec_policy_compatible(request, psec_supports_deny_paths) + } + + fn psec_policy_compatible(request: &ExecutionRequest, psec_supports_deny_paths: bool) -> bool { !request.policy.least_privilege_mode && !request.policy.network_proxy.is_enabled() && (request.policy.denied_paths.is_empty() || psec_supports_deny_paths) @@ -826,9 +906,43 @@ impl BaseContainerRunner { Self::is_process_security_environment_usable() } + /// Whether a `captureDenials` request is eligible for the native + /// (PSEC + Learning Mode) capture path, given the effective PSEC usability + /// and a [`CapturePlatformSupport`] probe. Shared by the instance + /// ([`Self::uses_process_security_environment`], probing `self.capture_support`) + /// and static ([`Self::uses_native_capture_for_request`], probing + /// [`RealCapturePlatformSupport`]) eligibility checks so the two cannot drift. + fn native_capture_eligible( + request: &ExecutionRequest, + psec_usable: bool, + support: &dyn CapturePlatformSupport, + ) -> bool { + #[cfg(test)] + let native_capture_usable = std::env::var("MXC_FORCE_NATIVE_CAPTURE_USABLE").map_or_else( + |_| psec_usable && support.check_apis(true).is_ok(), + |forced| forced == "1", + ); + #[cfg(not(test))] + let native_capture_usable = psec_usable && support.check_apis(true).is_ok(); + + request.policy.capture_denials.is_some() + && native_capture_usable + && Self::psec_policy_compatible( + request, + request.policy.denied_paths.is_empty() + || support.supports_deny_paths().unwrap_or(false), + ) + } + fn uses_process_security_environment(&self, request: &ExecutionRequest) -> bool { - let supports_deny_paths = request.policy.capture_denials.is_some() - || request.policy.denied_paths.is_empty() + if request.policy.capture_denials.is_some() { + return Self::native_capture_eligible( + request, + self.process_security_environment_usable(), + self.capture_support.as_ref(), + ); + } + let supports_deny_paths = request.policy.denied_paths.is_empty() || self.capture_support.supports_deny_paths().unwrap_or(false); Self::should_use_process_security_environment( request, @@ -844,7 +958,16 @@ impl BaseContainerRunner { } let psec_usable = Self::is_process_security_environment_usable(); if request.policy.capture_denials.is_some() { - return psec_usable; + if Self::uses_native_capture_for_request(request) { + return true; + } + if !Self::legacy_sbox_compatible_with_request( + request, + Self::query_sandbox_capabilities(), + ) { + return false; + } + return Self::is_legacy_base_container_usable(); } let psec_supports_deny_paths = request.policy.denied_paths.is_empty() || SecurityEnvironmentApi::load() @@ -867,16 +990,30 @@ impl BaseContainerRunner { let psec_supports_deny_paths = SecurityEnvironmentApi::load() .and_then(|api| api.supports_deny_paths()) .unwrap_or(false); - if Self::should_use_process_security_environment( - request, - Self::is_process_security_environment_usable(), - psec_supports_deny_paths, - ) { + let uses_native_capture = Self::uses_native_capture_for_request(request); + if uses_native_capture { + return true; + } + if request.policy.capture_denials.is_none() + && Self::should_use_process_security_environment( + request, + Self::is_process_security_environment_usable(), + psec_supports_deny_paths, + ) + { return true; } crate::fallback_detector::base_container_supports_deny_paths() } + pub(crate) fn uses_native_capture_for_request(request: &ExecutionRequest) -> bool { + Self::native_capture_eligible( + request, + Self::is_process_security_environment_usable(), + &RealCapturePlatformSupport, + ) + } + fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(1024); let version = SchemaVersion::new(1, 0); @@ -1276,6 +1413,7 @@ impl BaseContainerRunner { let capture_denials = request.policy.capture_denials.clone(); let use_process_security_environment = self.uses_process_security_environment(&request); + let use_guarded_capture = capture_denials.is_some() && !use_process_security_environment; let spec_bytes = if !use_process_security_environment { let bytes = Self::build_sandbox_spec(&request); Self::log_sandbox_spec(&bytes, logger); @@ -1299,26 +1437,32 @@ impl BaseContainerRunner { // Resolve two paths for the capture: // * `capture_etl_path` — a runner-managed `.etl` in a protected - // per-run directory. The OS broker seals into it; `run_teardown` - // decodes it, then deletes it unless observable retention was - // requested. + // per-run directory for native V2 capture. Guarded WPR analyzes + // its ETL while elevated and returns only a bounded process-scoped + // result. // * `capture_output_path` — the JSON denials deliverable that consuming // apps read: caller-specified via `captureDenials.outputPath` when // provided, else a managed per-run temp `.json` file. - let mut managed_capture = capture_denials - .as_ref() - .map(|config| managed_capture_output_path(config.retain_etl)) - .transpose()?; + let mut managed_capture = if use_process_security_environment { + capture_denials + .as_ref() + .map(|config| managed_capture_output_path(config.retain_etl)) + .transpose()? + } else { + None + }; let capture_output_path = capture_denials .as_ref() .map(|config| unique_denials_output_path(config.output_path.as_deref())) - .transpose()?; + .transpose() + .map_err(|error| ScriptResponse::error(&error))?; let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Load API"); // Prefer the process-security-environment APIs whenever they are usable - // and compatible with the requested policy; otherwise use transitional - // SBOX. + // and compatible with the requested policy. Guarded capture deliberately + // retains SBOX when the complete native PSEC/V2 capture capability set + // is unavailable or policy-incompatible. let create_process_in_sandbox = if !use_process_security_environment { let api = match Self::load_api() { Ok(f) => f, @@ -1591,9 +1735,9 @@ impl BaseContainerRunner { let no_window_flag = if pipe_mode { CREATE_NO_WINDOW.0 } else { 0 }; // Create the child suspended so its main thread cannot spawn any // descendant before we've assigned it to the job object below; it is - // resumed right after the assignment. If the sandbox create API ignores - // CREATE_SUSPENDED on a given build, the child starts running anyway and - // the later resume is a harmless no-op. + // resumed right after the assignment. Guarded capture verifies below + // that the API honored CREATE_SUSPENDED; an already-running child would + // have executed before capture attachment and must fail closed. let creation_flags = CREATE_SUSPENDED.0 | no_window_flag | if env_block.is_some() { @@ -1825,7 +1969,7 @@ impl BaseContainerRunner { let capture_cleanup_error = capture_session .take() .and_then(|session| session.finish(None).err()); - if capture_denials.is_some() { + if capture_denials.is_some() && use_process_security_environment { self.cleanup_capture_begin_failure(logger); } else if legacy_destroy_on_exit { // The OS may have created the AppContainer profile before @@ -1909,10 +2053,9 @@ impl BaseContainerRunner { // // The child was created suspended (CREATE_SUSPENDED) and is resumed only // after this assignment, so no descendant it spawns can escape the job. - // If the create API ignores CREATE_SUSPENDED on a given build the child - // is already running; it is a shell that has not yet run the user - // command, so the pre-assignment window is empty in practice and the - // later resume is a harmless no-op. + // If the create API ignores CREATE_SUSPENDED on a given build, guarded + // capture rejects the launch below because its trace would be incomplete. + // Non-capture launches retain the historical harmless-no-op behavior. let job = match UiJobObject::new().and_then(|job| { // Pass the raw handle — `assign_process` borrows it and does not // take ownership. Wrapping it in a temporary `OwnedHandle` here @@ -1944,7 +2087,7 @@ impl BaseContainerRunner { let capture_cleanup_error = capture_session .take() .and_then(|session| session.finish(None).err()); - if capture_denials.is_some() { + if capture_denials.is_some() && use_process_security_environment { self.cleanup_capture_begin_failure(logger); } else if legacy_destroy_on_exit { run_sandbox_cleanup( @@ -1955,7 +2098,7 @@ impl BaseContainerRunner { ); sandbox_tracking::unregister_ctrl_c_cleanup(); } - if capture_denials.is_none() { + if !use_process_security_environment { self.proxy_coordinator.stop(logger); } @@ -1981,14 +2124,111 @@ impl BaseContainerRunner { } }; + let mut guarded_capture_session = if use_guarded_capture { + let factory = self + .guarded_capture_factory + .as_ref() + .ok_or_else(|| ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error( + "guarded WPR capture was selected without a capture factory", + ) + })?; + match factory.start(std::process::id()) { + Ok(mut session) => { + if let Err(attach_error) = + session.attach_process_tree(job.handle_value(), pi.hProcess.0 as usize) + { + let message = self.abandon_capture_launch( + &job, + pi.hProcess, + pi.hThread, + Some(session), + &identity, + &sid_string, + legacy_destroy_on_exit, + request.policy.network_proxy.is_enabled(), + "the suspended sandbox", + format!( + "captureDenials failed to attach the sandbox process tree to \ + guarded WPR before resuming the sandbox: {attach_error}" + ), + logger, + ); + return Err(ScriptResponse { + failure_phase: FailurePhase::LaunchFailed, + ..ScriptResponse::error(&message) + }); + } + Some(session) + } + Err(error) => { + let message = self.abandon_capture_launch( + &job, + pi.hProcess, + pi.hThread, + None, + &identity, + &sid_string, + legacy_destroy_on_exit, + request.policy.network_proxy.is_enabled(), + "the suspended sandbox", + format!( + "captureDenials failed to start guarded WPR before resuming the \ + sandbox: {error}" + ), + logger, + ); + return Err(ScriptResponse { + failure_phase: FailurePhase::LaunchFailed, + ..ScriptResponse::error(&message) + }); + } + } + } else { + None + }; + // The child was created suspended; now that it is in the job object (so - // every descendant it spawns is captured), resume its main thread. If the - // create API ignored CREATE_SUSPENDED the thread is already running and - // this is a harmless no-op. + // every descendant it spawns is captured), resume its main thread. // SAFETY: `pi.hThread` is the just-created, still-owned main-thread // handle; `ResumeThread` only adjusts its suspend count. - unsafe { - ResumeThread(pi.hThread); + let previous_suspend_count = unsafe { ResumeThread(pi.hThread) }; + let resume_error = if previous_suspend_count == u32::MAX { + Some(format!( + "ResumeThread failed for the BaseContainer child: {:?}", + unsafe { GetLastError() } + )) + } else if guarded_capture_started_too_late( + previous_suspend_count, + guarded_capture_session.is_some(), + ) { + Some( + "the legacy BaseContainer API ignored CREATE_SUSPENDED, so guarded WPR could not \ + observe the complete sandbox execution" + .to_string(), + ) + } else { + None + }; + if let Some(message) = resume_error { + let message = self.abandon_capture_launch( + &job, + pi.hProcess, + pi.hThread, + guarded_capture_session.take(), + &identity, + &sid_string, + legacy_destroy_on_exit, + request.policy.network_proxy.is_enabled(), + "the sandbox process tree", + message, + logger, + ); + return Err(ScriptResponse { + failure_phase: FailurePhase::LaunchFailed, + ..ScriptResponse::error(&message) + }); } // Hand ownership to the caller via `BaseChild`, which performs @@ -2010,6 +2250,7 @@ impl BaseContainerRunner { sid_string, proxy_coordinator: std::mem::take(&mut self.proxy_coordinator), capture_session, + guarded_capture_session, security_environment, managed_capture: managed_capture.take(), capture_output_path, @@ -2020,10 +2261,10 @@ impl BaseContainerRunner { } } -/// A BaseContainer child launched by [`BaseContainerRunner::spawn_base`]. The -/// child runs immediately (no suspend); this owns the process handle, the -/// parent-side pipe ends, and the per-run proxy/sandbox state it tears down -/// once the child exits. +/// A BaseContainer child launched by [`BaseContainerRunner::spawn_base`]. +/// `spawn_base` resumes it only after job assignment and any guarded-capture +/// attachment. This owns the process handle, parent-side pipe ends, and the +/// per-run proxy/sandbox state it tears down once the child exits. struct BaseChild { process: OwnedHandle, thread: OwnedHandle, @@ -2046,6 +2287,9 @@ struct BaseChild { /// is configured and the OS API is available). Sealed in `run_teardown` /// after the child exits. capture_session: Option>, + /// Live guarded WPR session used when the legacy SBOX tier supplies + /// containment and native PSEC/V2 capture is unavailable. + guarded_capture_session: Option>, /// Non-capture PSEC environment retained until the child exits so policy /// enforcement outlives the process tree. security_environment: Option, @@ -2061,14 +2305,39 @@ struct BaseChild { impl SandboxBackend for BaseContainerRunner { fn validate(&self, request: &ExecutionRequest) -> Result<(), ScriptResponse> { let capture_denials = request.policy.capture_denials.is_some(); + if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { + return Err(ScriptResponse::error( + wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, + )); + } + // Dry-run validates the schema and policy shape without selecting or + // probing a host capture provider. + if request.dry_run { + return Ok(()); + } let use_process_security_environment = self.uses_process_security_environment(request); - if capture_denials && !use_process_security_environment { + if !use_process_security_environment + && request + .policy + .capture_denials + .as_ref() + .is_some_and(|config| config.retain_etl) + { + return Err(ScriptResponse { + failure_phase: FailurePhase::BackendUnavailable, + ..ScriptResponse::error(crate::guarded_capture::RETAIN_ETL_UNSUPPORTED_MSG) + }); + } + if capture_denials + && !use_process_security_environment + && self.guarded_capture_factory.is_none() + { return Err(ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, ..ScriptResponse::error( - "processContainer.captureDenials requires the official process \ - security-environment APIs; this host can only use a legacy \ - ProcessContainer fallback", + "processContainer.captureDenials requires either the complete native \ + PSEC/V2 Learning Mode API set or an explicitly configured guarded-WPR \ + fallback", ) }); } @@ -2084,19 +2353,9 @@ impl SandboxBackend for BaseContainerRunner { until it can supply the required proxy AppContainer peer identity", )); } - if !request.policy.allowed_hosts.is_empty() || !request.policy.blocked_hosts.is_empty() { - return Err(ScriptResponse::error( - wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, - )); - } - // Dry-run validates the schema and policy shape without requiring the - // current host to expose the selected schema's OS APIs. - if request.dry_run { - return Ok(()); - } - if use_process_security_environment { + if use_process_security_environment && !capture_denials { self.capture_support - .check_apis(capture_denials) + .check_apis(false) .map_err(|detail| ScriptResponse { failure_phase: FailurePhase::BackendUnavailable, ..ScriptResponse::error(&format!( @@ -2206,6 +2465,7 @@ struct BaseContainerSandboxProcess { /// Live learning-mode capture session, moved from the `BaseChild`. Sealed /// in `run_teardown` once the child has exited and been reaped. capture_session: Option>, + guarded_capture_session: Option>, /// Non-capture PSEC environment, closed after the child exits and is reaped. security_environment: Option, /// Protected per-run ETL path and its cleanup guard. @@ -2254,6 +2514,7 @@ impl BaseContainerSandboxProcess { proxy_coordinator: std::mem::take(&mut child.proxy_coordinator), teardown_result: None, capture_session: child.capture_session.take(), + guarded_capture_session: child.guarded_capture_session.take(), security_environment: child.security_environment.take(), managed_capture: child.managed_capture.take(), capture_output_path: child.capture_output_path.take(), @@ -2408,6 +2669,33 @@ impl BaseContainerSandboxProcess { self.managed_capture.take(); Ok(None) }; + let guarded_capture_result = if let Some(mut session) = self.guarded_capture_session.take() + { + let output_path = self.capture_output_path.take(); + let exit_code = self.last_exit_code.unwrap_or(-1); + let result = session + .stop_analyzed() + .map_err(|error| { + std::io::Error::other(format!( + "captureDenials failed to stop and analyze guarded WPR: {error}" + )) + }) + .and_then(|analysis| { + let output_path = output_path.ok_or_else(|| { + std::io::Error::other("captureDenials output path was not initialized") + })?; + write_denials_document(analysis, exit_code, &output_path) + }); + if let Ok(metadata) = &result { + self.output_metadata = Some(SandboxOutputMetadata { + capture_denials: Some(metadata.clone()), + capture_denials_error: None, + }); + } + result.map(Some) + } else { + Ok(None) + }; self.security_environment.take(); if self.destroy_on_exit { @@ -2421,27 +2709,64 @@ impl BaseContainerSandboxProcess { } self.proxy_coordinator.stop(&mut logger); let result = capture_result + .and(guarded_capture_result) .map(|_| ()) .map_err(|error| error.to_string()); self.teardown_result = Some(result.clone()); result.map_err(std::io::Error::other) } + fn release_guarded_capture_after_termination_failure(&mut self) { + let Some(session) = self.guarded_capture_session.take() else { + return; + }; + // The trait contract keeps this call blocked until the elevated + // guardian has released its duplicate job handle, even when discard + // itself fails. Only then may Drop return and release enforcement. + if let Err(error) = crate::guarded_capture::release_after_termination_failure(session) { + write_stderr_line_best_effort(format_args!( + "failed to discard guarded WPR capture after sandbox termination failure: {error}" + )); + } + } + fn kill_process_tree(&mut self) -> std::io::Result<()> { if let Some(job) = &self.job { - job.terminate(u32::MAX); - } else { - unsafe { - let _ = TerminateProcess(self.process.get(), u32::MAX); + if self.guarded_capture_session.is_some() { + // Guarded-WPR capture needs strict drain certainty: the ETL is + // only safely scoped if the job is proven to have fully drained + // before the trace is stopped/discarded. + job.terminate_and_wait(u32::MAX) + .map_err(|error| std::io::Error::other(error.to_string()))?; + } else { + // Ordinary run: terminate the tree, but a slow drain is a + // warning, not a hard failure that would discard an otherwise + // valid result. + match job.terminate_best_effort(u32::MAX) { + Ok(Some(drain_warning)) => write_stderr_line_best_effort(format_args!( + "sandbox job did not fully drain within the teardown window \ + (continuing): {drain_warning}" + )), + Ok(None) => {} + Err(error) => return Err(std::io::Error::other(error.to_string())), + } } + } else { + unsafe { TerminateProcess(self.process.get(), u32::MAX) } + .map_err(|error| std::io::Error::other(format!("TerminateProcess: {error}")))?; } Ok(()) } - fn terminate_and_reap(&mut self) { - let _ = self.kill_process_tree(); + fn terminate_and_reap(&mut self) -> std::io::Result<()> { + self.kill_process_tree()?; unsafe { - let _ = WaitForSingleObject(self.process.get(), u32::MAX); + match WaitForSingleObject(self.process.get(), u32::MAX) { + WAIT_OBJECT_0 => Ok(()), + status => Err(std::io::Error::other(format!( + "WaitForSingleObject(process) returned {status:?}" + ))), + } } } @@ -2457,25 +2782,7 @@ impl BaseContainerSandboxProcess { "captureDenials failed to decode denials ETL: {error}" )) })?; - - let summary = DenialSummary::new( - exit_code, - analysis.denials.len(), - analysis.denied_resources_truncated, - ); - let document = DenialsDocument::new(analysis.denials, summary); - - write_denials_output_file(output_path, |writer| write_document(writer, &document))?; - - let pointer = DenialsOutputPointer::new(output_path.to_string_lossy(), &document.summary); - Ok(CaptureDenialsOutput { - kind: pointer.kind, - output_path: pointer.output_path, - exit_code: pointer.exit_code, - total_denials: pointer.total_denials, - denied_resources_truncated: pointer.denied_resources_truncated, - etl_path: None, - }) + write_denials_document(analysis, exit_code, output_path) } fn decode_write_and_finalize( @@ -2522,7 +2829,7 @@ fn finalize_capture_result( combine_capture_output_and_cleanup_results( capture_result, etl_path - .map(|path| remove_internal_capture_file(path, etl_directory)) + .map(|path| remove_managed_capture_path(path, etl_directory)) .unwrap_or(Ok(())), ) } @@ -2559,7 +2866,7 @@ fn finalize_capture_seal_failure( combine_capture_and_cleanup_results( Err(capture_error), etl_path - .map(|path| remove_internal_capture_file(path, etl_directory)) + .map(|path| remove_managed_capture_path(path, etl_directory)) .unwrap_or(Ok(())), ) } @@ -2577,15 +2884,8 @@ fn discard_abandoned_capture( result } -fn remove_internal_capture_file(path: &Path, directory: Option<&Path>) -> std::io::Result<()> { - let file_result = match std::fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(std::io::Error::other(format!( - "captureDenials failed to remove internal ETL file {}: {error}", - path.display() - ))), - }; +fn remove_managed_capture_path(path: &Path, directory: Option<&Path>) -> std::io::Result<()> { + let file_result = remove_internal_capture_file(path); let directory_result = match directory { Some(directory) => match std::fs::remove_dir(directory) { Ok(()) => Ok(()), @@ -2605,94 +2905,6 @@ fn remove_internal_capture_file(path: &Path, directory: Option<&Path>) -> std::i ))), } } - -fn combine_capture_and_cleanup_results( - capture_result: std::io::Result, - cleanup_result: std::io::Result<()>, -) -> std::io::Result { - match (capture_result, cleanup_result) { - (Ok(value), Ok(())) => Ok(value), - (Err(capture_error), Ok(())) => Err(capture_error), - (Ok(_), Err(cleanup_error)) => Err(cleanup_error), - (Err(capture_error), Err(cleanup_error)) => Err(std::io::Error::other(format!( - "{capture_error}; additionally failed to clean up the internal ETL: {cleanup_error}" - ))), - } -} - -fn write_stderr_line_best_effort(message: std::fmt::Arguments<'_>) { - let stderr = std::io::stderr(); - let mut stderr = stderr.lock(); - let _ = std::io::Write::write_fmt(&mut stderr, format_args!("{message}\n")); - let _ = std::io::Write::flush(&mut stderr); -} - -fn write_denials_output_file( - output_path: &Path, - write: impl FnOnce(&mut std::io::BufWriter) -> std::io::Result<()>, -) -> std::io::Result<()> { - let file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(output_path) - .map_err(|error| { - std::io::Error::other(format!( - "captureDenials failed to create denials output file {}: {error}", - output_path.display() - )) - })?; - - let write_result = { - let mut writer = std::io::BufWriter::new(file); - write(&mut writer) - }; - if let Err(error) = write_result { - let write_error = std::io::Error::other(format!( - "captureDenials failed to write denials output file {}: {error}", - output_path.display() - )); - return match std::fs::remove_file(output_path) { - Ok(()) => Err(write_error), - Err(cleanup_error) if cleanup_error.kind() == std::io::ErrorKind::NotFound => { - Err(write_error) - } - Err(cleanup_error) => Err(std::io::Error::other(format!( - "{write_error}; additionally failed to remove incomplete output file {}: {cleanup_error}", - output_path.display() - ))), - }; - } - - Ok(()) -} - -/// Inserts a per-run identifier into a denials output path's file stem so -/// concurrent and sequential captures using the same configured `outputPath` -/// produce distinct files instead of clobbering one another. -/// -/// `C:\app\denials.json` → `C:\app\denials..json`. A path with no -/// extension gets `.`; a bare filename (no parent) keeps its -/// directory-less form. -fn insert_run_id_into_stem(path: &Path, run_id: &str) -> PathBuf { - let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else { - return path.to_path_buf(); - }; - let new_name = match path.extension().and_then(|s| s.to_str()) { - Some(ext) => { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(file_name); - format!("{stem}.{run_id}.{ext}") - } - None => format!("{file_name}.{run_id}"), - }; - match path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent.join(new_name), - _ => PathBuf::from(new_name), - } -} - impl SandboxProcess for BaseContainerSandboxProcess { fn output_metadata(&self) -> Option<&SandboxOutputMetadata> { self.output_metadata.as_ref() @@ -2779,9 +2991,10 @@ impl SandboxProcess for BaseContainerSandboxProcess { // failure this also terminates it. Then reap the root before releasing // the pipe drains — and killing the tree closes the descendant's pipe // write-ends, so the drains can finish. - self.terminate_and_reap(); + let termination_result = self.terminate_and_reap(); cancel_and_join_discard(stdout_thread, &self.stdout_canceller); cancel_and_join_discard(stderr_thread, &self.stderr_canceller); + termination_result?; // Record the child's exit code so `run_teardown` can stamp it into the // denials summary. On a timeout / wait failure there is no exit code. self.last_exit_code = result.as_ref().ok().copied(); @@ -2795,7 +3008,13 @@ impl Drop for BaseContainerSandboxProcess { // Kill and reap before tearing down proxy / sandbox state, so an // abandoned-but-running sandbox cannot outlive its enforcement (or // leak as an orphan). - self.terminate_and_reap(); + if let Err(error) = self.terminate_and_reap() { + write_stderr_line_best_effort(format_args!( + "failed to terminate sandbox process tree during drop: {error}" + )); + self.release_guarded_capture_after_termination_failure(); + return; + } // A dropped handle has no observer for output metadata, so retaining // its ETL would leave a sensitive artifact with no discoverable owner. // If wait already attempted teardown, it already reported any failure. @@ -2828,7 +3047,7 @@ impl ManagedCapturePath { impl Drop for ManagedCapturePath { fn drop(&mut self) { if self.armed { - let _ = remove_internal_capture_file(&self.etl_path, Some(&self.directory)); + let _ = remove_managed_capture_path(&self.etl_path, Some(&self.directory)); } } } @@ -2917,7 +3136,8 @@ fn managed_capture_output_path_in( } for _ in 0..8 { - let suffix = random_capture_suffix()?; + let suffix = crate::capture_output::random_capture_suffix() + .map_err(|error| ScriptResponse::error(&error))?; let directory = root.join(format!("{directory_prefix}{}_{suffix}", std::process::id())); match std::fs::create_dir(&directory) { Ok(()) => { @@ -2951,43 +3171,6 @@ fn managed_capture_output_path_in( )) } -fn unique_denials_output_path(configured_path: Option<&str>) -> Result { - let suffix = random_capture_suffix()?; - let run_id = format!("{}_{suffix}", std::process::id()); - Ok(match configured_path { - Some(path) => insert_run_id_into_stem(Path::new(path), &run_id), - None => std::env::temp_dir().join(format!("mxc_denials_{run_id}.json")), - }) -} - -fn random_capture_suffix() -> Result { - let mut nonce = [0u8; 16]; - getrandom::getrandom(&mut nonce).map_err(|error| { - ScriptResponse::error(&format!( - "captureDenials could not generate a unique output path: {error}" - )) - })?; - Ok(nonce - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::()) -} - -fn combine_process_and_teardown_results( - process_result: std::io::Result, - teardown_result: std::io::Result<()>, -) -> std::io::Result { - match (process_result, teardown_result) { - (Ok(exit_code), Ok(())) => Ok(exit_code), - (Ok(_), Err(teardown_error)) => Err(teardown_error), - (Err(wait_error), Ok(())) => Err(wait_error), - (Err(wait_error), Err(teardown_error)) => Err(std::io::Error::new( - wait_error.kind(), - format!("{wait_error}; captureDenials teardown also failed: {teardown_error}"), - )), - } -} - /// Derive the AppContainer SID string from a container identity name. /// Best-effort: returns a placeholder if derivation fails. fn derive_sid_string_from_name(name: &str) -> String { @@ -3017,7 +3200,7 @@ mod tests { use super::*; use crate::job_object::to_job_object_uilimit_mask; use learning_mode_core::{ - AccessType, AnalysisResult, AnalyzeError, DeniedResource, ResourceType, + AccessType, AnalysisResult, AnalyzeError, DenialsDocument, DeniedResource, ResourceType, }; use process_security_environment_spec::process_security_environment_layout as psec_layout; use sandbox_spec::base_container_layout; @@ -3025,6 +3208,13 @@ mod tests { use wxc_common::models::{ClipboardPolicy, ProxyConfig, UiPolicy}; use wxc_common::ui_policy::EffectiveUiRestrictions; + #[test] + fn guarded_capture_rejects_a_child_that_was_never_suspended() { + assert!(guarded_capture_started_too_late(0, true)); + assert!(!guarded_capture_started_too_late(1, true)); + assert!(!guarded_capture_started_too_late(0, false)); + } + struct FakeCaptureSession { finish_error: Option<(&'static str, i32)>, finish_calls: Arc, @@ -3183,66 +3373,6 @@ mod tests { ); } - #[test] - fn managed_denials_paths_are_unique_per_run() { - let first = unique_denials_output_path(None).expect("first path"); - let second = unique_denials_output_path(None).expect("second path"); - - assert_ne!(first, second); - assert_eq!(first.parent(), Some(std::env::temp_dir().as_path())); - assert_eq!(second.parent(), Some(std::env::temp_dir().as_path())); - assert_eq!(first.extension().and_then(|ext| ext.to_str()), Some("json")); - } - - #[test] - fn failed_denials_write_removes_incomplete_output() { - let directory = tempfile::tempdir().expect("temp directory"); - let output_path = directory.path().join("denials.json"); - - let error = write_denials_output_file(&output_path, |writer| { - std::io::Write::write_all(writer, b"{\"partial\":")?; - Err(std::io::Error::other("simulated write failure")) - }) - .expect_err("write should fail"); - - assert!(error.to_string().contains("simulated write failure")); - assert!(!output_path.exists()); - } - - #[test] - fn denials_output_does_not_overwrite_an_existing_file() { - let directory = tempfile::tempdir().expect("temp directory"); - let output_path = directory.path().join("denials.json"); - std::fs::write(&output_path, b"existing").expect("seed output"); - - write_denials_output_file(&output_path, |_| Ok(())).expect_err("collision should fail"); - - assert_eq!( - std::fs::read(&output_path).expect("read existing output"), - b"existing" - ); - } - - #[test] - fn missing_internal_etl_is_already_clean() { - let directory = tempfile::tempdir().expect("temp directory"); - let missing = directory.path().join("missing.etl"); - remove_internal_capture_file(&missing, None).expect("missing file should be clean"); - } - - #[test] - fn capture_and_etl_cleanup_failures_are_both_preserved() { - let error = combine_capture_and_cleanup_results::<()>( - Err(std::io::Error::other("decode failed")), - Err(std::io::Error::other("delete failed")), - ) - .expect_err("combined operation should fail"); - - let message = error.to_string(); - assert!(message.contains("decode failed")); - assert!(message.contains("delete failed")); - } - #[test] fn cleanup_failure_preserves_successful_capture_output() { let output = CaptureDenialsOutput { @@ -3537,30 +3667,6 @@ mod tests { assert!(message.contains(r"C:\Temp\capture.etl")); } - #[test] - fn insert_run_id_into_stem_injects_id_before_extension() { - let got = insert_run_id_into_stem(Path::new(r"C:\app\denials.json"), "1234_abcd"); - assert_eq!(got, PathBuf::from(r"C:\app\denials.1234_abcd.json")); - } - - #[test] - fn insert_run_id_into_stem_handles_no_extension() { - let got = insert_run_id_into_stem(Path::new(r"C:\app\denials"), "77_abcd"); - assert_eq!(got, PathBuf::from(r"C:\app\denials.77_abcd")); - } - - #[test] - fn insert_run_id_into_stem_handles_bare_filename() { - let got = insert_run_id_into_stem(Path::new("denials.json"), "9_abcd"); - assert_eq!(got, PathBuf::from("denials.9_abcd.json")); - } - - #[test] - fn insert_run_id_into_stem_preserves_multi_dot_stem() { - let got = insert_run_id_into_stem(Path::new(r"C:\app\out.denials.json"), "5_abcd"); - assert_eq!(got, PathBuf::from(r"C:\app\out.denials.5_abcd.json")); - } - #[test] fn is_api_not_implemented_classifies_disabled_feature() { assert!(is_api_not_implemented(ERROR_CALL_NOT_IMPLEMENTED.0)); @@ -3943,6 +4049,35 @@ mod tests { ); } + #[test] + fn capture_proxy_uses_guarded_contract() { + let _guard = crate::test_env::CaptureCapabilityGuard::set(true, true); + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + request.policy.network_proxy = ProxyConfig { + address: Some(ProxyAddress::new("127.0.0.1".to_string(), 8080)), + builtin_test_server: false, + }; + let support = Arc::new(FakeCaptureSupport { + api_error: None, + deny_error: None, + deny_supported: true, + api_calls: AtomicUsize::new(0), + learning_mode_api_calls: AtomicUsize::new(0), + deny_calls: AtomicUsize::new(0), + }); + let runner = BaseContainerRunner::with_capture_components(fake_capture_factory(), support); + + assert!( + !runner.uses_process_security_environment(&request), + "capture must not select PSEC when another requested policy is incompatible" + ); + assert!( + !BaseContainerRunner::uses_native_capture_for_request(&request), + "dispatcher capability selection must reject policy-incompatible PSEC capture" + ); + } + #[test] fn least_privilege_uses_legacy_contract() { let mut request = ExecutionRequest::default(); @@ -4247,7 +4382,8 @@ mod tests { } #[test] - fn capture_validation_fails_closed_when_v2_api_is_unavailable() { + fn capture_validation_requires_guarded_fallback_when_v2_api_is_unavailable() { + let _guard = crate::test_env::lock(); let factory = fake_capture_factory(); let support = Arc::new(FakeCaptureSupport { api_error: Some("missing CloseLearningModeTrace"), @@ -4264,9 +4400,7 @@ mod tests { .expect_err("missing V2 API must fail closed"); assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); - assert!(error - .error_message - .contains("missing CloseLearningModeTrace")); + assert!(error.error_message.contains("guarded-WPR fallback")); assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.learning_mode_api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.deny_calls.load(Ordering::SeqCst), 0); @@ -4274,7 +4408,8 @@ mod tests { } #[test] - fn capture_validation_fails_closed_when_deny_query_fails() { + fn capture_validation_requires_guarded_fallback_when_native_deny_query_fails() { + let _guard = crate::test_env::lock(); let factory = fake_capture_factory(); let support = Arc::new(FakeCaptureSupport { api_error: None, @@ -4291,14 +4426,15 @@ mod tests { .expect_err("deny query failure must fail closed"); assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); - assert!(error.error_message.contains("query failed")); + assert!(error.error_message.contains("guarded-WPR fallback")); assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.deny_calls.load(Ordering::SeqCst), 1); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); } #[test] - fn capture_validation_fails_closed_when_deny_bit_is_clear() { + fn capture_validation_requires_guarded_fallback_when_native_deny_bit_is_clear() { + let _guard = crate::test_env::lock(); let factory = fake_capture_factory(); let support = Arc::new(FakeCaptureSupport { api_error: None, @@ -4315,7 +4451,7 @@ mod tests { .expect_err("missing deny support bit must fail closed"); assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); - assert_eq!(error.error_message, PSEC_DENIED_PATHS_UNSUPPORTED_MSG); + assert!(error.error_message.contains("guarded-WPR fallback")); assert_eq!(support.api_calls.load(Ordering::SeqCst), 1); assert_eq!(support.deny_calls.load(Ordering::SeqCst), 1); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); @@ -4435,4 +4571,34 @@ mod tests { assert_eq!(support.learning_mode_api_calls.load(Ordering::SeqCst), 0); assert_eq!(factory.begin_calls.load(Ordering::SeqCst), 0); } + + #[test] + fn validate_runner_rejects_etl_retention_when_guarded_fallback_is_selected() { + // Hold the shared env lock and force native capture unavailable, so a + // concurrent capability-guarded test cannot leak + // `MXC_FORCE_NATIVE_CAPTURE_USABLE=1` and flip this runner onto the + // native (non-guarded) path — which would skip the retain-ETL rejection + // under test. `psec_usable_override` alone is insufficient because the + // env override takes precedence in `native_capture_eligible`. + let _capture_guard = crate::test_env::CaptureCapabilityGuard::set(false, false); + let runner = BaseContainerRunner { + psec_usable_override: Some(false), + ..Default::default() + }; + let mut request = ExecutionRequest::default(); + request.policy.capture_denials = Some(wxc_common::models::CaptureDenialsConfig { + retain_etl: true, + ..Default::default() + }); + + let error = runner + .validate(&request) + .expect_err("guarded capture must not expose its host-wide ETL"); + + assert_eq!(error.failure_phase, FailurePhase::BackendUnavailable); + assert_eq!( + error.error_message, + crate::guarded_capture::RETAIN_ETL_UNSUPPORTED_MSG + ); + } } diff --git a/src/backends/appcontainer/common/src/capture_output.rs b/src/backends/appcontainer/common/src/capture_output.rs new file mode 100644 index 000000000..f81f3e2b0 --- /dev/null +++ b/src/backends/appcontainer/common/src/capture_output.rs @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Shared `processContainer.captureDenials` JSON-output plumbing. +//! +//! Both the native BaseContainer capture path (`base_container_runner`, +//! decoding its own sealed ETL) and the guarded-WPR legacy-tier fallback +//! (`appcontainer_runner`, consuming an already-decoded [`AnalysisResult`] +//! handed back by the elevated PLM guardian) must emit byte-for-byte the same +//! [`DenialsDocument`] JSON shape, [`CaptureDenialsOutput`] summary, and +//! resolved output-path convention. Centralizing that here is what guarantees +//! the two paths can't drift. +//! +//! Deliberately free of any Windows API or [`ScriptResponse`] coupling so it +//! can be shared by both runner modules without either owning the other's +//! error type; callers map the plain `String` errors into their own error +//! type at the call site. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use learning_mode_core::{ + write_document, AnalysisResult, DenialSummary, DenialsDocument, DenialsOutputPointer, +}; +use wxc_common::models::CaptureDenialsOutput; + +/// Writes a bounded [`AnalysisResult`] to `output_path` as the JSON denials +/// document and returns the caller-facing [`CaptureDenialsOutput`] summary. +/// +/// Never overwrites an existing file: a run whose output path collides with a +/// leftover file from a previous run fails loudly rather than clobbering it. +pub fn write_denials_document( + analysis: AnalysisResult, + exit_code: i32, + output_path: &Path, +) -> std::io::Result { + let summary = DenialSummary::new( + exit_code, + analysis.denials.len(), + analysis.denied_resources_truncated, + ); + let document = DenialsDocument::new(analysis.denials, summary); + + write_denials_output_file(output_path, |writer| write_document(writer, &document))?; + + let pointer = DenialsOutputPointer::new(output_path.to_string_lossy(), &document.summary); + Ok(CaptureDenialsOutput { + kind: pointer.kind, + output_path: pointer.output_path, + exit_code: pointer.exit_code, + total_denials: pointer.total_denials, + denied_resources_truncated: pointer.denied_resources_truncated, + etl_path: None, + }) +} + +/// Creates `output_path` (failing if it already exists) and writes through +/// `write`, cleaning up a partial file if `write` fails. +pub fn write_denials_output_file( + output_path: &Path, + write: impl FnOnce(&mut std::io::BufWriter) -> std::io::Result<()>, +) -> std::io::Result<()> { + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(output_path) + .map_err(|error| { + std::io::Error::other(format!( + "captureDenials failed to create denials output file {}: {error}", + output_path.display() + )) + })?; + + let write_result = { + let mut writer = std::io::BufWriter::new(file); + write(&mut writer).and_then(|()| writer.flush()) + }; + if let Err(error) = write_result { + let write_error = std::io::Error::other(format!( + "captureDenials failed to write denials output file {}: {error}", + output_path.display() + )); + return match std::fs::remove_file(output_path) { + Ok(()) => Err(write_error), + Err(cleanup_error) if cleanup_error.kind() == std::io::ErrorKind::NotFound => { + Err(write_error) + } + Err(cleanup_error) => Err(std::io::Error::other(format!( + "{write_error}; additionally failed to remove incomplete output file {}: {cleanup_error}", + output_path.display() + ))), + }; + } + + Ok(()) +} + +/// Inserts a per-run identifier into a denials output path's file stem so +/// concurrent and sequential captures using the same configured `outputPath` +/// produce distinct files instead of clobbering one another. +/// +/// `C:\app\denials.json` → `C:\app\denials..json`. A path with no +/// extension gets `.`; a bare filename (no parent) keeps its +/// directory-less form. +pub fn insert_run_id_into_stem(path: &Path, run_id: &str) -> PathBuf { + let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else { + return path.to_path_buf(); + }; + let new_name = match path.extension().and_then(|s| s.to_str()) { + Some(ext) => { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file_name); + format!("{stem}.{run_id}.{ext}") + } + None => format!("{file_name}.{run_id}"), + }; + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.join(new_name), + _ => PathBuf::from(new_name), + } +} + +/// Resolves the JSON denials deliverable path for a run: the caller's +/// configured path with a per-run identifier stamped into the stem, or a +/// managed per-run temp file when unset. +pub fn unique_denials_output_path(configured_path: Option<&str>) -> Result { + let suffix = random_capture_suffix()?; + let run_id = format!("{}_{suffix}", std::process::id()); + Ok(match configured_path { + Some(path) => insert_run_id_into_stem(Path::new(path), &run_id), + None => std::env::temp_dir().join(format!("mxc_denials_{run_id}.json")), + }) +} + +/// A short random hex suffix used to keep per-run temp/output paths from +/// colliding across concurrent or sequential runs sharing the same PID. +pub fn random_capture_suffix() -> Result { + let mut nonce = [0u8; 16]; + getrandom::getrandom(&mut nonce).map_err(|error| { + format!("captureDenials could not generate a unique output path: {error}") + })?; + Ok(nonce + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::()) +} + +/// Removes an internal (runner-managed) capture temp file. Treats "already +/// gone" as success so a redundant cleanup call is harmless. +pub fn remove_internal_capture_file(path: &Path) -> std::io::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(std::io::Error::other(format!( + "captureDenials failed to remove internal capture file {}: {error}", + path.display() + ))), + } +} + +/// Combines a primary result with a best-effort secondary `()` result, keeping +/// the four-arm pattern in one place. On success the primary value flows +/// through; if exactly one side fails its error is returned unchanged; if both +/// fail, `combine_errors` merges them (owning both errors so callers control +/// the resulting message and [`std::io::ErrorKind`]). +fn combine_results( + primary: std::io::Result, + secondary: std::io::Result<()>, + combine_errors: impl FnOnce(std::io::Error, std::io::Error) -> std::io::Error, +) -> std::io::Result { + match (primary, secondary) { + (Ok(value), Ok(())) => Ok(value), + (Err(primary_error), Ok(())) => Err(primary_error), + (Ok(_), Err(secondary_error)) => Err(secondary_error), + (Err(primary_error), Err(secondary_error)) => { + Err(combine_errors(primary_error, secondary_error)) + } + } +} + +/// Combines a capture result with a best-effort cleanup result, preserving +/// both failure messages when both operations fail. +pub fn combine_capture_and_cleanup_results( + capture_result: std::io::Result, + cleanup_result: std::io::Result<()>, +) -> std::io::Result { + combine_results( + capture_result, + cleanup_result, + |capture_error, cleanup_error| { + std::io::Error::other(format!( + "{capture_error}; additionally failed to clean up the internal capture state: {cleanup_error}" + )) + }, + ) +} + +/// Combines a sandboxed process's wait result with a best-effort +/// `captureDenials` teardown result, preferring the teardown error when both +/// are present so a capture failure is never silently swallowed by a +/// successful process exit. Shared by the native BaseContainer capture path +/// and the guarded-WPR legacy-tier fallback so both surface capture failures +/// through [`wxc_common::sandbox_process::SandboxProcess::wait`] identically. +/// +/// When *both* the wait and teardown fail, the wait error kind is preserved +/// while both messages are returned. This keeps retained-ETL paths and other +/// capture recovery details discoverable. +pub fn combine_process_and_teardown_results( + process_result: std::io::Result, + teardown_result: std::io::Result<()>, +) -> std::io::Result { + combine_results( + process_result, + teardown_result, + |wait_error, teardown_error| { + std::io::Error::new( + wait_error.kind(), + format!("{wait_error}; captureDenials teardown also failed: {teardown_error}"), + ) + }, + ) +} + +/// Best-effort write of a single diagnostic line to stderr, used for failures +/// that occur too late (e.g. during `Drop`) to be returned as a `Result`. +pub fn write_stderr_line_best_effort(message: std::fmt::Arguments<'_>) { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + let _ = std::io::Write::write_fmt(&mut stderr, format_args!("{message}\n")); + let _ = std::io::Write::flush(&mut stderr); +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_core::{AccessType, DeniedResource, ResourceType}; + + #[test] + fn write_denials_document_writes_summary_and_document() { + let directory = tempfile::tempdir().expect("temp directory"); + let output_path = directory.path().join("denials.json"); + let analysis = AnalysisResult::complete(vec![DeniedResource { + resource: r"C:\blocked.txt".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 42, + filetime: 99, + }]); + + let metadata = + write_denials_document(analysis, 7, &output_path).expect("write should succeed"); + + assert_eq!(metadata.kind, CaptureDenialsOutput::KIND); + assert_eq!(metadata.exit_code, 7); + assert_eq!(metadata.total_denials, 1); + assert!(!metadata.denied_resources_truncated); + let document: DenialsDocument = + serde_json::from_slice(&std::fs::read(output_path).unwrap()).unwrap(); + assert_eq!(document.denials.len(), 1); + } + + #[test] + fn write_denials_document_handles_empty_result() { + let directory = tempfile::tempdir().expect("temp directory"); + let output_path = directory.path().join("denials.json"); + + let metadata = + write_denials_document(AnalysisResult::complete(Vec::new()), 0, &output_path) + .expect("write should succeed"); + + assert_eq!(metadata.total_denials, 0); + assert!(output_path.exists()); + } + + #[test] + fn failed_denials_write_removes_incomplete_output() { + let directory = tempfile::tempdir().expect("temp directory"); + let output_path = directory.path().join("denials.json"); + + let error = write_denials_output_file(&output_path, |writer| { + std::io::Write::write_all(writer, b"{\"partial\":")?; + Err(std::io::Error::other("simulated write failure")) + }) + .expect_err("write should fail"); + + assert!(error.to_string().contains("simulated write failure")); + assert!(!output_path.exists()); + } + + #[test] + fn denials_output_does_not_overwrite_an_existing_file() { + let directory = tempfile::tempdir().expect("temp directory"); + let output_path = directory.path().join("denials.json"); + std::fs::write(&output_path, b"existing").expect("seed output"); + + write_denials_output_file(&output_path, |_| Ok(())).expect_err("collision should fail"); + + assert_eq!( + std::fs::read(&output_path).expect("read existing output"), + b"existing" + ); + } + + #[test] + fn insert_run_id_into_stem_injects_id_before_extension() { + let got = insert_run_id_into_stem(Path::new(r"C:\app\denials.json"), "1234_abcd"); + assert_eq!(got, PathBuf::from(r"C:\app\denials.1234_abcd.json")); + } + + #[test] + fn insert_run_id_into_stem_handles_no_extension() { + let got = insert_run_id_into_stem(Path::new(r"C:\app\denials"), "77_abcd"); + assert_eq!(got, PathBuf::from(r"C:\app\denials.77_abcd")); + } + + #[test] + fn insert_run_id_into_stem_handles_bare_filename() { + let got = insert_run_id_into_stem(Path::new("denials.json"), "9_abcd"); + assert_eq!(got, PathBuf::from("denials.9_abcd.json")); + } + + #[test] + fn insert_run_id_into_stem_preserves_multi_dot_stem() { + let got = insert_run_id_into_stem(Path::new(r"C:\app\out.denials.json"), "5_abcd"); + assert_eq!(got, PathBuf::from(r"C:\app\out.denials.5_abcd.json")); + } + + #[test] + fn managed_denials_paths_are_unique_per_run() { + let first = unique_denials_output_path(None).expect("first path"); + let second = unique_denials_output_path(None).expect("second path"); + + assert_ne!(first, second); + assert_eq!(first.parent(), Some(std::env::temp_dir().as_path())); + assert_eq!(second.parent(), Some(std::env::temp_dir().as_path())); + assert_eq!(first.extension().and_then(|ext| ext.to_str()), Some("json")); + } + + #[test] + fn missing_internal_capture_file_is_already_clean() { + let directory = tempfile::tempdir().expect("temp directory"); + let missing = directory.path().join("missing.etl"); + remove_internal_capture_file(&missing).expect("missing file should be clean"); + } + + #[test] + fn capture_and_cleanup_failures_are_both_preserved() { + let error = combine_capture_and_cleanup_results::<()>( + Err(std::io::Error::other("decode failed")), + Err(std::io::Error::other("delete failed")), + ) + .expect_err("combined operation should fail"); + + let message = error.to_string(); + assert!(message.contains("decode failed")); + assert!(message.contains("delete failed")); + } + + #[test] + fn successful_process_reports_capture_teardown_failure() { + let error = + combine_process_and_teardown_results(Ok(0), Err(std::io::Error::other("seal failed"))) + .expect_err("capture failure must override successful process exit"); + + assert!(error.to_string().contains("seal failed")); + } + + #[test] + fn failed_process_result_is_preserved_over_successful_teardown() { + let error = + combine_process_and_teardown_results(Err(std::io::Error::other("wait failed")), Ok(())) + .expect_err("wait failure should propagate"); + + assert!(error.to_string().contains("wait failed")); + } + + #[test] + fn wait_failure_takes_precedence_when_teardown_also_fails() { + let error = combine_process_and_teardown_results( + Err(std::io::Error::other("wait failed")), + Err(std::io::Error::other("teardown failed")), + ) + .expect_err("wait failure should still propagate"); + + assert!(error.to_string().contains("wait failed")); + assert!(error.to_string().contains("teardown failed")); + } +} diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index 6a1ac3cf8..02af9bc57 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -66,10 +66,12 @@ //! `#[cfg(target_os = "windows")]`; no inner attribute is needed. use std::path::PathBuf; +use std::sync::Arc; use crate::appcontainer_runner::{derive_sid_string, AppContainerScriptRunner, FilesystemMode}; use crate::base_container_runner::BaseContainerRunner; use crate::fallback_detector::{self, FallbackError, IsolationTier}; +use crate::guarded_capture::GuardedCaptureFactory; use wxc_common::error::WxcError; use wxc_common::filesystem_dacl::{DaclError, DaclManager, RO_MASK, RW_MASK}; use wxc_common::logger::Logger; @@ -321,7 +323,21 @@ impl SandboxBackend for SelectedBackend { /// Run tier selection and construct the backend + (optional) DACL guard for /// `request`. This is the single source of truth for the tier → (backend, DACL) /// mapping, shared by the run-to-completion ([`dispatch_with_fallback`]) and -/// streaming ([`spawn_with_fallback`]) surfaces. +/// streaming ([`spawn_with_fallback`]) surfaces (and their capture-aware +/// counterparts, [`dispatch_with_fallback_and_capture`] / +/// [`spawn_with_fallback_and_capture`]). +/// +/// `capture_factory` is the optional guarded-WPR-capture DI boundary (see +/// [`crate::guarded_capture`]). When `request.policy.capture_denials` is set +/// and the selected tier is not the native BaseContainer backend: +/// - `capture_factory` present → the factory is threaded onto the chosen +/// AppContainer runner via `with_guarded_capture_factory`, so the runner +/// itself performs the guarded WPR fallback capture (see +/// `appcontainer_runner`'s `validate`/`spawn`). +/// - `capture_factory` absent → dispatch fails closed with +/// [`DispatchError::CaptureDenialsUnsupported`], preserving the legacy +/// behavior for callers that haven't opted into the fallback (e.g. callers +/// without a Windows-only `plm` dependency available). /// /// On success the returned [`DaclManager`], when present, has **already applied /// its ACEs** and MUST outlive the run (its `Drop` restores the host ACEs). The @@ -329,6 +345,7 @@ impl SandboxBackend for SelectedBackend { /// telemetry. fn select_backend_with_fallback( request: &ExecutionRequest, + capture_factory: Option<&Arc>, ) -> Result< ( SelectedBackend, @@ -343,6 +360,7 @@ fn select_backend_with_fallback( // otherwise uses the transitional SBOX contract. If neither BaseContainer // contract is usable, detection continues to the AppContainer tiers. let prefer_base_container = BaseContainerRunner::is_usable_for_request(request); + let uses_native_capture = BaseContainerRunner::uses_native_capture_for_request(request); let supports_deny_paths = BaseContainerRunner::supports_deny_paths_for_request(request); let decision = fallback_detector::detect_with_base_container_capabilities( &request.policy, @@ -350,11 +368,26 @@ fn select_backend_with_fallback( prefer_base_container, supports_deny_paths, )?; - if request.policy.capture_denials.is_some() && decision.tier != IsolationTier::BaseContainer { + let guarded_capture_required = request.policy.capture_denials.is_some() + && (decision.tier != IsolationTier::BaseContainer || !uses_native_capture); + if guarded_capture_required && capture_factory.is_none() { return Err(DispatchError::CaptureDenialsUnsupported { tier: decision.tier, }); } + // Only thread the factory into the runner when it will actually be used — + // an AppContainer tier honoring `captureDenials`. Reuse the already-derived + // `guarded_capture_required` rather than re-deriving the condition from + // `capture_denials`: for every AppContainer tier the two are equivalent + // (those arms only run when `tier != BaseContainer`), and in the + // BaseContainer arm this value is unused. This keeps + // T1/T2-without-capture/T3-without-capture identical to their pre-fallback + // construction. + let capture_factory_for_appcontainer = if guarded_capture_required { + capture_factory + } else { + None + }; let (backend, dacl_manager): (SelectedBackend, Option) = match decision.tier { IsolationTier::BaseContainer => { // Tier 1 delegates filesystem-policy enforcement to @@ -362,10 +395,14 @@ fn select_backend_with_fallback( // here: the detector only routes a denied-paths policy to T1 // when the OS enforces `fs_deny` natively, so there is nothing // for a host DACL to add. - ( - SelectedBackend::BaseContainer(BaseContainerRunner::new()), - None, - ) + let runner = if guarded_capture_required { + BaseContainerRunner::new().with_guarded_capture_factory(Arc::clone( + capture_factory.expect("guarded capture factory checked above"), + )) + } else { + BaseContainerRunner::new() + }; + (SelectedBackend::BaseContainer(runner), None) } IsolationTier::AppContainerBfs => { // T2 only needs deny ACEs (BFS handles the rest in-runner) @@ -374,12 +411,11 @@ fn select_backend_with_fallback( // common no-deny case skips both costs. let denied = paths_to_pathbufs(&request.policy.denied_paths); if denied.is_empty() { - ( - SelectedBackend::AppContainer(AppContainerScriptRunner::with_filesystem_mode( - FilesystemMode::Bfs, - )), - None, - ) + let runner = with_capture_factory( + AppContainerScriptRunner::with_filesystem_mode(FilesystemMode::Bfs), + capture_factory_for_appcontainer, + ); + (SelectedBackend::AppContainer(runner), None) } else { let sid = derive_sid_string(&container_name(request)).map_err(DispatchError::Sid)?; @@ -387,15 +423,27 @@ fn select_backend_with_fallback( // Hand the derived SID string to the runner so it does // not re-run `ConvertSidToStringSidW` for the firewall // principal-id lookup. - ( - SelectedBackend::AppContainer( - AppContainerScriptRunner::with_filesystem_mode_and_sid_string( - FilesystemMode::Bfs, - sid, - ), - ), - mgr, - ) + // + // BFS cannot enforce `deniedPaths` itself. Marking them + // "externally enforced" — so the runner's `validate` accepts + // them and relies on the host deny-only DACL built above — is a + // capture-fallback affordance, gated to `captureDenials` + // requests. For non-capture requests we leave the runner + // unmarked, so `deniedPaths` on the BFS tier stay unsupported + // exactly as before this fallback existed (the runner's + // `validate` rejects them) rather than silently broadening BFS + // to honor `deniedPaths` via host DACLs. + let base_runner = AppContainerScriptRunner::with_filesystem_mode_and_sid_string( + FilesystemMode::Bfs, + sid, + ); + let base_runner = if guarded_capture_required { + base_runner.with_external_denied_paths() + } else { + base_runner + }; + let runner = with_capture_factory(base_runner, capture_factory_for_appcontainer); + (SelectedBackend::AppContainer(runner), mgr) } } IsolationTier::AppContainerDacl => { @@ -423,21 +471,34 @@ fn select_backend_with_fallback( let denied = paths_to_pathbufs(&request.policy.denied_paths); let sid = derive_sid_string(&container_name(request)).map_err(DispatchError::Sid)?; let mgr = build_t3_dacl(&sid, &readwrite, &readonly, &denied)?; - ( - SelectedBackend::AppContainer( - AppContainerScriptRunner::with_filesystem_mode_and_sid_string( - FilesystemMode::Dacl, - sid, - ), + let runner = with_capture_factory( + AppContainerScriptRunner::with_filesystem_mode_and_sid_string( + FilesystemMode::Dacl, + sid, ), - Some(mgr), - ) + capture_factory_for_appcontainer, + ); + (SelectedBackend::AppContainer(runner), Some(mgr)) } }; Ok((backend, dacl_manager, decision.tier, decision.warnings)) } +/// Chain [`AppContainerScriptRunner::with_guarded_capture_factory`] onto +/// `runner` when `capture_factory` is present, otherwise return `runner` +/// unchanged. Small helper to keep the three tier-selection arms above +/// symmetric regardless of whether `captureDenials` is in play. +fn with_capture_factory( + runner: AppContainerScriptRunner, + capture_factory: Option<&Arc>, +) -> AppContainerScriptRunner { + match capture_factory { + Some(factory) => runner.with_guarded_capture_factory(Arc::clone(factory)), + None => runner, + } +} + /// Build a runner with appropriate DACL augmentation for the /// BaseContainer-preferred path. The caller is responsible for the explicit /// (no-fallback) AppContainer path. @@ -446,8 +507,29 @@ fn select_backend_with_fallback( /// execute and (when applicable) a [`DaclManager`] that has already /// applied its ACEs. Use [`Dispatched::into_runner_and_guard`] to /// extract both; the manager MUST stay alive through the run. +/// +/// This is the legacy, capture-unaware entrypoint: `captureDenials` on a +/// non-BaseContainer tier always fails closed. Use +/// [`dispatch_with_fallback_and_capture`] to additionally opt into the +/// guarded-WPR fallback. pub fn dispatch_with_fallback(request: &ExecutionRequest) -> Result { - let (backend, dacl_manager, tier, warnings) = select_backend_with_fallback(request)?; + dispatch_with_fallback_and_capture(request, None) +} + +/// Capture-aware counterpart of [`dispatch_with_fallback`]: identical tier +/// selection, but when `request.policy.capture_denials` is set and the +/// selected tier is an AppContainer fallback (not the native BaseContainer +/// backend), `capture_factory` — when present — is threaded onto the chosen +/// runner via `with_guarded_capture_factory` so the runner performs a guarded +/// WPR fallback capture instead of failing closed. +/// +/// Passing `None` is equivalent to [`dispatch_with_fallback`]. +pub fn dispatch_with_fallback_and_capture( + request: &ExecutionRequest, + capture_factory: Option>, +) -> Result { + let (backend, dacl_manager, tier, warnings) = + select_backend_with_fallback(request, capture_factory.as_ref())?; let runner: Box = Box::new(Runner::new(backend)); Ok(Dispatched { runner, @@ -513,13 +595,34 @@ pub enum SpawnDispatchError { /// tearing down firewall / BFS enforcement) **before** the [`DaclManager`] /// (restoring host ACEs) — the same order the run-to-completion path enforces /// via [`Dispatched::into_runner_and_guard`]. +/// +/// This is the legacy, capture-unaware entrypoint: `captureDenials` on a +/// non-BaseContainer tier always fails closed. Use +/// [`spawn_with_fallback_and_capture`] to additionally opt into the +/// guarded-WPR fallback. pub fn spawn_with_fallback( request: &ExecutionRequest, logger: &mut Logger, stdio: StdioMode, +) -> Result { + spawn_with_fallback_and_capture(request, logger, stdio, None) +} + +/// Capture-aware counterpart of [`spawn_with_fallback`]: identical tier +/// selection and spawn behavior, but threads `capture_factory` through to +/// [`select_backend_with_fallback`] so an AppContainer fallback tier can +/// perform a guarded WPR capture instead of failing closed when +/// `request.policy.capture_denials` is set. Passing `None` is equivalent to +/// [`spawn_with_fallback`]. +pub fn spawn_with_fallback_and_capture( + request: &ExecutionRequest, + logger: &mut Logger, + stdio: StdioMode, + capture_factory: Option>, ) -> Result { let (mut backend, dacl_manager, tier, warnings) = - select_backend_with_fallback(request).map_err(SpawnDispatchError::Dispatch)?; + select_backend_with_fallback(request, capture_factory.as_ref()) + .map_err(SpawnDispatchError::Dispatch)?; // Spawn with the DACL ACEs (if any) already applied. On a spawn failure the // `dacl_manager` local drops here, restoring any ACEs that were stamped; we @@ -604,6 +707,10 @@ impl SandboxProcess for DaclGuardedProcess { self.inner.wait() } + fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { + self.inner.output_metadata() + } + fn stdout_closer(&self) -> Option> { self.inner.stdout_closer() } @@ -622,7 +729,7 @@ mod tests { // a dispatcher test and a fallback-detector test running on // different threads could each mutate `MXC_FORCE_TIER` under // independent locks and race. - use crate::test_env::{BcUsableGuard, ForceTierGuard, ENV_LOCK}; + use crate::test_env::{BcUsableGuard, CaptureCapabilityGuard, ForceTierGuard, ENV_LOCK}; fn test_request(policy: ContainerPolicy) -> ExecutionRequest { ExecutionRequest { @@ -649,6 +756,21 @@ mod tests { .push(dir.path().to_string_lossy().into_owned()); (p, dir) } + + /// A fake [`GuardedCaptureFactory`] used only to exercise dispatcher + /// tier-selection gating — its `start` is never invoked by these tests + /// (dispatch stops at backend construction, before `spawn`). + struct FakeGuardedCaptureFactory; + + impl GuardedCaptureFactory for FakeGuardedCaptureFactory { + fn start( + &self, + _owner_pid: u32, + ) -> Result, String> { + Err("not used in dispatcher selection tests".to_string()) + } + } + #[test] fn dispatch_t1_no_denied_paths_no_dacl() { let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); @@ -714,6 +836,109 @@ mod tests { )); } + #[test] + fn capture_denials_rejects_appcontainer_fallback_via_capture_entrypoint_without_factory() { + // The capture-aware entrypoint with `None` must behave identically to + // the legacy `dispatch_with_fallback` fail-closed path. + let _g = ForceTierGuard::set("appcontainer-dacl"); + let (mut policy, _tmp) = policy_with_rw_temp(); + policy.capture_denials = Some(Default::default()); + let req = test_request(policy); + + let result = dispatch_with_fallback_and_capture(&req, None); + assert!(matches!( + result, + Err(DispatchError::CaptureDenialsUnsupported { + tier: IsolationTier::AppContainerDacl + }) + )); + } + + #[test] + fn capture_denials_selects_appcontainer_fallback_with_guarded_factory() { + // With a guarded capture factory supplied, an AppContainer fallback + // tier must be selected (not rejected) when `captureDenials` is set. + let _g = ForceTierGuard::set("appcontainer-dacl"); + let (mut policy, _tmp) = policy_with_rw_temp(); + policy.capture_denials = Some(Default::default()); + let req = test_request(policy); + + let factory: Arc = Arc::new(FakeGuardedCaptureFactory); + let dispatched = dispatch_with_fallback_and_capture(&req, Some(factory)) + .expect("a guarded capture factory should let AppContainer+DACL honor captureDenials"); + assert!(matches!(dispatched.tier, IsolationTier::AppContainerDacl)); + assert!( + dispatched.has_dacl_guard(), + "T3 still stamps its grant ACEs when captureDenials is honored via guarded WPR" + ); + } + + #[test] + fn capture_denials_prefers_native_psec_v2_when_complete() { + let _guard = CaptureCapabilityGuard::set(true, true); + let mut policy = empty_policy(); + policy.capture_denials = Some(Default::default()); + let request = test_request(policy); + + let dispatched = dispatch_with_fallback(&request) + .expect("complete native capture should not require guarded WPR"); + + assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); + } + + #[test] + fn capture_denials_selects_legacy_sbox_with_guarded_factory() { + let _guard = CaptureCapabilityGuard::set(true, false); + let mut policy = empty_policy(); + policy.capture_denials = Some(Default::default()); + let request = test_request(policy); + let factory: Arc = Arc::new(FakeGuardedCaptureFactory); + + let dispatched = dispatch_with_fallback_and_capture(&request, Some(factory)) + .expect("legacy SBOX should pair with guarded WPR"); + + assert!(matches!(dispatched.tier, IsolationTier::BaseContainer)); + assert!(!dispatched.has_dacl_guard()); + } + + #[test] + fn capture_denials_rejects_legacy_sbox_without_guarded_factory() { + let _guard = CaptureCapabilityGuard::set(true, false); + let mut policy = empty_policy(); + policy.capture_denials = Some(Default::default()); + let request = test_request(policy); + + let result = dispatch_with_fallback(&request); + + assert!(matches!( + result, + Err(DispatchError::CaptureDenialsUnsupported { + tier: IsolationTier::BaseContainer + }) + )); + } + + #[test] + fn spawn_with_fallback_and_capture_none_matches_legacy_rejection() { + // `spawn_with_fallback_and_capture(..., None)` must fail closed the + // same way the run-to-completion entrypoint does. + let _g = ForceTierGuard::set("appcontainer-dacl"); + let (mut policy, _tmp) = policy_with_rw_temp(); + policy.capture_denials = Some(Default::default()); + let req = test_request(policy); + let mut logger = Logger::new(wxc_common::logger::Mode::Buffer); + + let result = spawn_with_fallback_and_capture(&req, &mut logger, StdioMode::Inherit, None); + assert!(matches!( + result, + Err(SpawnDispatchError::Dispatch( + DispatchError::CaptureDenialsUnsupported { + tier: IsolationTier::AppContainerDacl + } + )) + )); + } + #[test] fn ordinary_request_keeps_appcontainer_fallback() { let _g = ForceTierGuard::set("appcontainer-dacl"); @@ -739,7 +964,7 @@ mod tests { let req = test_request(policy); let (backend, dacl, tier, _warnings) = - select_backend_with_fallback(&req).expect("SBOX should remain eligible"); + select_backend_with_fallback(&req, None).expect("SBOX should remain eligible"); assert!(matches!(tier, IsolationTier::BaseContainer)); assert!(matches!(backend, SelectedBackend::BaseContainer(_))); assert!(dacl.is_none()); @@ -755,8 +980,8 @@ mod tests { }; let req = test_request(policy); - let (backend, _dacl, tier, _warnings) = - select_backend_with_fallback(&req).expect("AppContainer fallback should be selected"); + let (backend, _dacl, tier, _warnings) = select_backend_with_fallback(&req, None) + .expect("AppContainer fallback should be selected"); assert_ne!(tier, IsolationTier::BaseContainer); assert!(matches!(backend, SelectedBackend::AppContainer(_))); } @@ -935,7 +1160,7 @@ mod tests { let _g = ForceTierGuard::set_tier(IsolationTier::BaseContainer); let req = test_request(empty_policy()); let (backend, dacl, tier, _w) = - select_backend_with_fallback(&req).expect("T1 selection should succeed"); + select_backend_with_fallback(&req, None).expect("T1 selection should succeed"); assert!(matches!(tier, IsolationTier::BaseContainer)); assert!( matches!(backend, SelectedBackend::BaseContainer(_)), @@ -952,7 +1177,7 @@ mod tests { let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); let req = test_request(empty_policy()); let (backend, dacl, tier, _w) = - select_backend_with_fallback(&req).expect("T2 selection should succeed"); + select_backend_with_fallback(&req, None).expect("T2 selection should succeed"); assert!(matches!(tier, IsolationTier::AppContainerBfs)); assert!(matches!(backend, SelectedBackend::AppContainer(_))); assert!( @@ -967,7 +1192,7 @@ mod tests { let (policy, _tmp) = policy_with_denied_temp(); let req = test_request(policy); let (backend, dacl, tier, _w) = - select_backend_with_fallback(&req).expect("T2+deny selection should succeed"); + select_backend_with_fallback(&req, None).expect("T2+deny selection should succeed"); assert!(matches!(tier, IsolationTier::AppContainerBfs)); assert!(matches!(backend, SelectedBackend::AppContainer(_))); assert!( @@ -976,13 +1201,39 @@ mod tests { ); } + #[test] + fn select_backend_t2_non_capture_deny_still_rejects_denied_paths() { + // Item 15 regression: on the BFS tier a *non-capture* deniedPaths + // request must NOT be broadened to honor deniedPaths via a host DACL. + // The selected runner is left un-marked (no `with_external_denied_paths`), + // so its `validate` still rejects deniedPaths exactly as before the + // guarded-capture fallback existed. (The deny DACL is still built, + // preserving the has-DACL selection asserted above; only the runner's + // acceptance is gated.) + let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerBfs); + let (policy, _tmp) = policy_with_denied_temp(); + let req = test_request(policy); + let (backend, _dacl, tier, _w) = + select_backend_with_fallback(&req, None).expect("T2+deny selection should succeed"); + assert!(matches!(tier, IsolationTier::AppContainerBfs)); + + let error = backend + .validate(&req) + .expect_err("non-capture deniedPaths on the BFS tier must be rejected"); + assert!( + error.error_message.contains("deniedPaths"), + "expected a deniedPaths rejection, got: {}", + error.error_message + ); + } + #[test] fn select_backend_t3_builds_appcontainer_with_dacl() { let _g = ForceTierGuard::set_tier(IsolationTier::AppContainerDacl); let (policy, _tmp) = policy_with_rw_temp(); let req = test_request(policy); let (backend, dacl, tier, _w) = - select_backend_with_fallback(&req).expect("T3 selection should succeed"); + select_backend_with_fallback(&req, None).expect("T3 selection should succeed"); assert!(matches!(tier, IsolationTier::AppContainerDacl)); assert!(matches!(backend, SelectedBackend::AppContainer(_))); assert!( @@ -1002,7 +1253,7 @@ mod tests { let _g = BcUsableGuard::set(false); let req = test_request(empty_policy()); let (backend, dacl, tier, _w) = - select_backend_with_fallback(&req).expect("selection should succeed"); + select_backend_with_fallback(&req, None).expect("selection should succeed"); assert!(matches!(tier, IsolationTier::AppContainerDacl)); assert!(matches!(backend, SelectedBackend::AppContainer(_))); assert!(dacl.is_some()); @@ -1024,6 +1275,7 @@ mod tests { struct FakeProcess { stdin_taken: bool, killed: bool, + output_metadata: wxc_common::models::SandboxOutputMetadata, } impl SandboxProcess for FakeProcess { fn take_stdin(&mut self) -> Option> { @@ -1049,6 +1301,9 @@ mod tests { fn wait(&mut self) -> std::io::Result { Ok(7) } + fn output_metadata(&self) -> Option<&wxc_common::models::SandboxOutputMetadata> { + Some(&self.output_metadata) + } } let _scope = ScopedStateDir::new(); @@ -1068,5 +1323,9 @@ mod tests { assert!(matches!(guarded.wait(), Ok(7)), "wait() must delegate"); assert!(guarded.take_stdin().is_none(), "take_stdin() must delegate"); assert!(guarded.kill().is_ok(), "kill() must delegate"); + assert!( + guarded.output_metadata().is_some(), + "output_metadata() must delegate" + ); } } diff --git a/src/backends/appcontainer/common/src/guarded_capture.rs b/src/backends/appcontainer/common/src/guarded_capture.rs new file mode 100644 index 000000000..fd8d0a905 --- /dev/null +++ b/src/backends/appcontainer/common/src/guarded_capture.rs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Dependency-injection boundary for the guarded WPR capture fallback. +//! +//! `appcontainer_common` implements the legacy containment tiers (BaseContainer +//! SBOX, AppContainer + BFS, AppContainer + DACL) that a host without the +//! native V2 PSEC + Learning Mode APIs still needs `captureDenials` on. +//! Elevated WPR capture lives in `plm` (the host's guarded PLM tool), and +//! `appcontainer_common` MUST NOT depend on `plm` directly: `plm` links the +//! Windows ETL decoder (`learning_mode_windows`) and elevation/pipe machinery +//! that is unrelated to this crate's job, and the crate-layering rule +//! (backend-support crates don't cross-depend on one another) forbids it. +//! +//! Instead, this module defines the minimal traits a legacy-tier runner needs +//! to start and stop a guarded WPR capture scoped to its own sandboxed process +//! tree. `mxc_engine` (which already depends on `plm` for the executor +//! binaries' guarded-PLM lifecycle) implements them by adapting +//! `plm::elevated::{start_guarded_session_with_executable, GuardedSession}`, +//! and hands the concrete factory to the dispatcher only when it explicitly +//! opts a request into the fallback (see +//! `dispatcher::dispatch_with_fallback_and_capture` / +//! `dispatcher::spawn_with_fallback_and_capture`) — a runner never picks up +//! guarded capture silently. + +use learning_mode_core::AnalysisResult; + +/// Guarded WPR returns only bounded process-scoped analysis; its raw host-wide +/// ETL cannot be exposed through caller-visible output. +pub const RETAIN_ETL_UNSUPPORTED_MSG: &str = + "processContainer.captureDenials.retainEtl requires native PSEC/V2 capture; \ + guarded-WPR fallback cannot return the raw host-wide ETL"; + +/// A live guarded WPR capture session scoped to one sandboxed process tree. +/// +/// Implementations own the elevated PLM child connection. [`stop_analyzed`] +/// asks the guardian to stop the host-wide WPR trace and decode it, returning +/// only the bounded, process-scoped [`AnalysisResult`] — the raw ETL never +/// crosses back into this process, satisfying the "raw host-wide ETL must +/// never cross into SDK output" requirement. +/// +/// [`stop_analyzed`]: GuardedCaptureSession::stop_analyzed +pub trait GuardedCaptureSession: Send { + /// Duplicates and attaches the caller's sandbox job and still-owned + /// suspended root process in the elevated guardian. Both values must be + /// HANDLEs owned by the authenticated unelevated process. + fn attach_process_tree( + &mut self, + job_handle: usize, + root_process_handle: usize, + ) -> Result<(), String>; + + /// Stops the owned WPR trace and securely discards its raw ETL without + /// analysis. Used when job attachment, sandbox launch, or sandbox + /// termination fails. + /// + /// This method must not return, on either success or error, until the + /// elevated guardian has terminated and released every duplicated sandbox + /// handle. Runners rely on that guarantee before allowing firewall, + /// filesystem, and DACL enforcement guards to drop. + fn discard(&mut self) -> Result<(), String>; + + /// Stops the guarded capture and analyzes it against exact process + /// generations: the guardian-attested root handle lifetime plus descendants + /// reconciled from retained handles and job membership accounting. + /// + /// # Errors + /// + /// Returns a human-readable message if the guardian connection is gone, + /// the stop/analyze round trip fails, or the guardian reports a decode + /// error. + fn stop_analyzed(&mut self) -> Result; +} + +/// Starts a [`GuardedCaptureSession`] for the calling (unelevated) process. +/// +/// Implementations are constructed by a higher layer (`mxc_engine`) that can +/// depend on `plm`; `appcontainer_common` only ever sees the trait object. +pub trait GuardedCaptureFactory: Send + Sync { + /// Starts a new guarded WPR capture session. + /// + /// `owner_pid` is the calling (unelevated) process's own OS process id — + /// used by the elevated guardian to authenticate the connection — **not** + /// the sandboxed child's pid. The child identity and lifetime are attested + /// later from its duplicated process handle; no caller-supplied child PID + /// or timestamp is trusted. + /// + /// # Errors + /// + /// Returns a human-readable message on failure (elevation refused, + /// guardian unreachable, a WPR session is already active, etc.). The + /// caller must terminate the still-suspended sandboxed child on failure + /// rather than resume it, so no active trace is ever left behind. + fn start(&self, owner_pid: u32) -> Result, String>; +} + +pub(crate) fn release_after_termination_failure( + mut session: Box, +) -> Result<(), String> { + session.discard() +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_core::{AccessType, DeniedResource, ResourceType}; + + /// A fake session/factory pair proving both traits are object-safe and + /// usable behind `dyn` references — the shape the dispatcher stores them + /// in ([`crate::guarded_capture`] traits are only ever consumed as trait + /// objects across the `appcontainer_common` / `mxc_engine` boundary). + struct FakeSession { + analysis: AnalysisResult, + } + + impl GuardedCaptureSession for FakeSession { + fn attach_process_tree( + &mut self, + job_handle: usize, + root_process_handle: usize, + ) -> Result<(), String> { + if job_handle == 0 || root_process_handle == 0 { + return Err("handles must be non-zero".to_string()); + } + Ok(()) + } + + fn discard(&mut self) -> Result<(), String> { + Ok(()) + } + + fn stop_analyzed(&mut self) -> Result { + Ok(self.analysis.clone()) + } + } + + struct FakeFactory; + + impl GuardedCaptureFactory for FakeFactory { + fn start(&self, owner_pid: u32) -> Result, String> { + if owner_pid == 0 { + return Err("owner pid must be non-zero".to_string()); + } + Ok(Box::new(FakeSession { + analysis: AnalysisResult::complete(vec![DeniedResource { + resource: r"C:\blocked.txt".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: owner_pid, + filetime: 1, + }]), + })) + } + } + + #[test] + fn factory_and_session_are_object_safe() { + let factory: Box = Box::new(FakeFactory); + let mut session = factory.start(1234).expect("start should succeed"); + session + .attach_process_tree(42, 43) + .expect("attach_process_tree should succeed"); + let analysis = session + .stop_analyzed() + .expect("stop_analyzed should succeed"); + assert_eq!(analysis.denials.len(), 1); + } + + #[test] + fn factory_rejects_zero_owner_pid() { + let factory: Box = Box::new(FakeFactory); + let error = match factory.start(0) { + Ok(_) => panic!("owner pid 0 must be rejected"), + Err(error) => error, + }; + assert!(error.contains("owner pid")); + } + + #[test] + fn release_waits_for_discard_contract_completion() { + struct BlockingSession { + entered: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + } + + impl GuardedCaptureSession for BlockingSession { + fn attach_process_tree( + &mut self, + _job_handle: usize, + _root_process_handle: usize, + ) -> Result<(), String> { + Ok(()) + } + + fn discard(&mut self) -> Result<(), String> { + self.entered.send(()).unwrap(); + self.release.recv().unwrap(); + Err("discard failed after guardian release".to_string()) + } + + fn stop_analyzed(&mut self) -> Result { + unreachable!() + } + } + + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + let result = release_after_termination_failure(Box::new(BlockingSession { + entered: entered_tx, + release: release_rx, + })); + done_tx.send(result).unwrap(); + }); + + entered_rx.recv().unwrap(); + assert!(done_rx + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err()); + release_tx.send(()).unwrap(); + assert_eq!( + done_rx.recv().unwrap().unwrap_err(), + "discard failed after guardian release" + ); + thread.join().unwrap(); + } +} diff --git a/src/backends/appcontainer/common/src/job_object.rs b/src/backends/appcontainer/common/src/job_object.rs index 2080d7ade..8e2404e27 100644 --- a/src/backends/appcontainer/common/src/job_object.rs +++ b/src/backends/appcontainer/common/src/job_object.rs @@ -6,24 +6,25 @@ //! plus the Windows-specific encoder that maps a platform-agnostic //! [`wxc_common::ui_policy::EffectiveUiRestrictions`] to the corresponding bitmask. //! -//! The wrapper owns the underlying job HANDLE and closes it on drop. Once a -//! process has been assigned to a job, the kernel keeps the restrictions -//! attached for the process lifetime regardless of whether the job HANDLE is -//! still open in the creator, so dropping a `UiJobObject` after assignment is -//! safe and does not relax the restrictions on the running process. +//! The wrapper owns the underlying job HANDLE and closes it on drop. Jobs are +//! configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so an abandoned +//! sandbox cannot outlive the process that owns its enforcement state. use core::ffi::c_void; use std::mem::size_of; use std::sync::OnceLock; +use std::time::{Duration, Instant}; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicUIRestrictions, - SetInformationJobObject, TerminateJobObject, JOBOBJECT_BASIC_UI_RESTRICTIONS, - JOB_OBJECT_UILIMIT, JOB_OBJECT_UILIMIT_DESKTOP, JOB_OBJECT_UILIMIT_DISPLAYSETTINGS, - JOB_OBJECT_UILIMIT_EXITWINDOWS, JOB_OBJECT_UILIMIT_GLOBALATOMS, JOB_OBJECT_UILIMIT_HANDLES, - JOB_OBJECT_UILIMIT_READCLIPBOARD, JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, - JOB_OBJECT_UILIMIT_WRITECLIPBOARD, + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicAccountingInformation, + JobObjectBasicUIRestrictions, JobObjectExtendedLimitInformation, QueryInformationJobObject, + SetInformationJobObject, TerminateJobObject, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_BASIC_UI_RESTRICTIONS, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_UILIMIT, JOB_OBJECT_UILIMIT_DESKTOP, + JOB_OBJECT_UILIMIT_DISPLAYSETTINGS, JOB_OBJECT_UILIMIT_EXITWINDOWS, + JOB_OBJECT_UILIMIT_GLOBALATOMS, JOB_OBJECT_UILIMIT_HANDLES, JOB_OBJECT_UILIMIT_READCLIPBOARD, + JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, JOB_OBJECT_UILIMIT_WRITECLIPBOARD, }; use windows::Win32::System::SystemServices::JOB_OBJECT_UILIMIT_IME; use windows_core::PCWSTR; @@ -109,6 +110,8 @@ const MIN_BUILD_FOR_IME_LIMIT: u32 = 22621; /// with `ERROR_INVALID_PARAMETER`, so it is excluded from the supported /// UI-limit set on those builds. const MIN_BUILD_FOR_INJECTION_LIMIT: u32 = 26100; +const JOB_EMPTY_WAIT_TIMEOUT: Duration = Duration::from_secs(5); +const JOB_EMPTY_POLL_INTERVAL: Duration = Duration::from_millis(10); /// Cached OS build number (queried once via `RtlGetVersion`). static OS_BUILD_NUMBER: OnceLock = OnceLock::new(); @@ -234,6 +237,23 @@ impl UiJobObject { // is documented to either return a valid HANDLE or an error. let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) } .map_err(|e| WxcError::Process(format!("CreateJobObjectW: {e}")))?; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if let Err(error) = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &limits as *const _ as *const c_void, + size_of::() as u32, + ) + } { + unsafe { + let _ = CloseHandle(handle); + } + return Err(WxcError::Process(format!( + "SetInformationJobObject(KILL_ON_JOB_CLOSE): {error}" + ))); + } Ok(Self { handle }) } @@ -275,16 +295,115 @@ impl UiJobObject { .map_err(|e| WxcError::Process(format!("AssignProcessToJobObject: {e}"))) } - /// Terminate every process currently assigned to this job (the sandboxed - /// child and all of its descendants) with the given exit code. Used to - /// tree-kill a running sandbox. Best-effort: errors are ignored since the - /// processes may already have exited. - pub fn terminate(&self, exit_code: u32) { + /// Returns this process's numeric HANDLE value for authenticated + /// cross-process duplication by the elevated guarded-WPR guardian. + pub fn handle_value(&self) -> usize { + self.handle.0 as usize + } + + /// Terminates every process currently assigned to this job (the sandboxed + /// child and all of its descendants) with the given exit code. + pub fn terminate(&self, exit_code: u32) -> Result<(), WxcError> { // SAFETY: `self.handle` is a valid job handle owned by this struct. - unsafe { - let _ = TerminateJobObject(self.handle, exit_code); + unsafe { TerminateJobObject(self.handle, exit_code) } + .map_err(|error| WxcError::Process(format!("TerminateJobObject: {error}"))) + } + + /// Waits until no processes remain assigned to the job. + pub fn wait_for_empty(&self) -> Result<(), WxcError> { + self.wait_for_empty_within(JOB_EMPTY_WAIT_TIMEOUT, JOB_EMPTY_POLL_INTERVAL) + } + + /// Waits up to `timeout` for the job to reach zero active processes, polling + /// job accounting every `poll_interval`. Extracted from [`Self::wait_for_empty`] + /// so callers (and tests) can supply an explicit bound; `Duration::ZERO` + /// performs exactly one accounting probe with no sleep. The timeout message + /// reports the configured `timeout`. + fn wait_for_empty_within( + &self, + timeout: Duration, + poll_interval: Duration, + ) -> Result<(), WxcError> { + let deadline = Instant::now() + timeout; + loop { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `self.handle` is a valid job handle and `accounting` + // matches the requested information class and buffer length. + unsafe { + QueryInformationJobObject( + Some(self.handle), + JobObjectBasicAccountingInformation, + &mut accounting as *mut _ as *mut c_void, + size_of::() as u32, + None, + ) + } + .map_err(|error| { + WxcError::Process(format!( + "QueryInformationJobObject(BasicAccounting): {error}" + )) + })?; + if accounting.ActiveProcesses == 0 { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(WxcError::Process(format!( + "timed out after {}ms waiting for sandbox job to become empty; {} process(es) \ + remain active", + timeout.as_millis(), + accounting.ActiveProcesses + ))); + } + std::thread::sleep(poll_interval); } } + + /// Terminates the complete process tree and confirms that the job is empty. + /// + /// This is the **strict** drain: a failure to observe the job reach zero + /// active processes within [`JOB_EMPTY_WAIT_TIMEOUT`] is returned as an + /// error. Use it only where full drain certainty is a correctness + /// requirement — notably the guarded-WPR `captureDenials` paths, where ETL + /// scoping is only sound if nothing can still be running unobserved. + /// Ordinary (non-capture) teardown should prefer + /// [`Self::terminate_best_effort`], which does not fail an otherwise-valid + /// run just because the kernel had not finished tearing the tree down + /// within the window. + pub fn terminate_and_wait(&self, exit_code: u32) -> Result<(), WxcError> { + self.terminate(exit_code)?; + self.wait_for_empty() + } + + /// Terminates the complete process tree, treating a drain-observation + /// timeout as a recoverable warning rather than a hard failure. + /// + /// A failure of [`Self::terminate`] itself (the actual `TerminateJobObject` + /// call) is still returned as an error. But if the job does not reach zero + /// active processes within the window, this returns `Ok(Some(error))` so + /// the caller can surface a warning while preserving the run's result — the + /// kernel continues tearing the tree down, and + /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` guarantees eventual teardown when + /// the handle closes. Returns `Ok(None)` when the job drained cleanly. + pub fn terminate_best_effort(&self, exit_code: u32) -> Result, WxcError> { + self.terminate_best_effort_within( + exit_code, + JOB_EMPTY_WAIT_TIMEOUT, + JOB_EMPTY_POLL_INTERVAL, + ) + } + + /// [`Self::terminate_best_effort`] with an explicit drain bound. Factored + /// out so the warning (drain-timeout) path is unit-testable with + /// `Duration::ZERO` instead of the multi-second production window. + fn terminate_best_effort_within( + &self, + exit_code: u32, + timeout: Duration, + poll_interval: Duration, + ) -> Result, WxcError> { + self.terminate(exit_code)?; + Ok(self.wait_for_empty_within(timeout, poll_interval).err()) + } } impl Drop for UiJobObject { @@ -302,6 +421,9 @@ impl Drop for UiJobObject { #[cfg(test)] mod tests { use super::*; + use std::os::windows::io::AsRawHandle; + use std::process::Command; + use std::time::{Duration, Instant}; #[test] fn create_set_limits_drop() { @@ -318,6 +440,90 @@ mod tests { drop(job); } + #[test] + fn dropping_job_terminates_assigned_process() { + let job = UiJobObject::new().expect("create job"); + let mut child = Command::new("cmd.exe") + .args(["/C", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .expect("spawn child"); + job.assign_process(HANDLE(child.as_raw_handle())) + .expect("assign child"); + + drop(job); + let deadline = Instant::now() + Duration::from_secs(10); + while child.try_wait().expect("query child").is_none() { + assert!( + Instant::now() < deadline, + "job-owned process survived job-handle close" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + + #[test] + fn terminate_and_wait_observes_job_accounting_reach_zero() { + let job = UiJobObject::new().expect("create job"); + let mut child = Command::new("cmd.exe") + .args(["/C", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .expect("spawn child"); + job.assign_process(HANDLE(child.as_raw_handle())) + .expect("assign child"); + + job.terminate_and_wait(u32::MAX) + .expect("terminated job should become empty"); + assert!(child.wait().expect("reap child").code().is_some()); + } + + #[test] + fn terminate_best_effort_reports_clean_drain() { + let job = UiJobObject::new().expect("create job"); + let mut child = Command::new("cmd.exe") + .args(["/C", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .expect("spawn child"); + job.assign_process(HANDLE(child.as_raw_handle())) + .expect("assign child"); + + let drain_warning = job + .terminate_best_effort(u32::MAX) + .expect("terminate itself must succeed"); + assert!( + drain_warning.is_none(), + "a terminated job that drains cleanly yields no warning: {drain_warning:?}" + ); + assert!(child.wait().expect("reap child").code().is_some()); + } + + #[test] + fn wait_for_empty_within_zero_timeout_reports_active_processes() { + // A single-probe (`Duration::ZERO`) wait against a still-running job + // deterministically reports the drain timeout without any real sleep. + // This is exactly the `WxcError` `terminate_best_effort` surfaces as its + // `Some(warning)` when a job has not drained within the window — testing + // it here keeps the coverage deterministic (a real `TerminateJobObject` + // followed by an immediate probe can race the async kernel teardown and + // observe the job already empty). + let job = UiJobObject::new().expect("create job"); + let mut child = Command::new("cmd.exe") + .args(["/C", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .expect("spawn child"); + job.assign_process(HANDLE(child.as_raw_handle())) + .expect("assign child"); + + let error = job + .wait_for_empty_within(Duration::ZERO, Duration::ZERO) + .expect_err("a live job must not be observed empty with a zero timeout"); + let message = error.to_string(); + assert!(message.contains("timed out"), "got: {message}"); + assert!(message.contains("remain active"), "got: {message}"); + + job.terminate_and_wait(u32::MAX).expect("cleanup terminate"); + child.wait().expect("reap child"); + } + #[test] fn encoder_known_bit_positions() { // Sanity-check that the encoder produces the documented winnt.h diff --git a/src/backends/appcontainer/common/src/lib.rs b/src/backends/appcontainer/common/src/lib.rs index a5579293c..7222a77cd 100644 --- a/src/backends/appcontainer/common/src/lib.rs +++ b/src/backends/appcontainer/common/src/lib.rs @@ -16,12 +16,16 @@ pub mod appcontainer_runner; #[cfg(target_os = "windows")] pub mod base_container_runner; #[cfg(target_os = "windows")] +pub mod capture_output; +#[cfg(target_os = "windows")] pub mod dispatcher; #[cfg(target_os = "windows")] pub mod fallback_detector; #[cfg(target_os = "windows")] pub mod filesystem_bfs; #[cfg(target_os = "windows")] +pub mod guarded_capture; +#[cfg(target_os = "windows")] pub mod job_object; #[cfg(target_os = "windows")] pub mod launch_diagnostics; diff --git a/src/backends/appcontainer/common/src/test_env.rs b/src/backends/appcontainer/common/src/test_env.rs index 6f9fd53fc..afe74eb61 100644 --- a/src/backends/appcontainer/common/src/test_env.rs +++ b/src/backends/appcontainer/common/src/test_env.rs @@ -22,10 +22,10 @@ use std::sync::{Mutex, MutexGuard}; /// Process-wide serialization for tests that mutate test-seam env /// vars. Tests in any module in this crate should acquire this lock /// (typically via [`ForceTierGuard`] / [`BfscfgPathGuard`]) before -/// reading or writing `MXC_FORCE_TIER` or `MXC_BFSCFG_PATH`. +/// reading or writing any MXC test-seam environment variable. pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(()); -fn lock() -> MutexGuard<'static, ()> { +pub(crate) fn lock() -> MutexGuard<'static, ()> { // Poison is irrelevant here: the env var is restored on Drop // regardless of whether a previous holder panicked, and the lock's // only purpose is to serialize accesses. @@ -102,6 +102,40 @@ impl Drop for BcUsableGuard { } } +/// Forces both BaseContainer usability and native-capture availability while +/// holding the shared environment lock. This distinguishes native PSEC/V2 +/// capture from legacy SBOX + guarded-WPR selection in dispatcher tests. +pub(crate) struct CaptureCapabilityGuard { + _lock: MutexGuard<'static, ()>, +} + +impl CaptureCapabilityGuard { + pub(crate) fn set(base_container_usable: bool, native_capture_usable: bool) -> Self { + let guard = lock(); + unsafe { + std::env::remove_var("MXC_FORCE_TIER"); + std::env::set_var( + "MXC_FORCE_BC_USABLE", + if base_container_usable { "1" } else { "0" }, + ); + std::env::set_var( + "MXC_FORCE_NATIVE_CAPTURE_USABLE", + if native_capture_usable { "1" } else { "0" }, + ); + } + Self { _lock: guard } + } +} + +impl Drop for CaptureCapabilityGuard { + fn drop(&mut self) { + unsafe { + std::env::remove_var("MXC_FORCE_BC_USABLE"); + std::env::remove_var("MXC_FORCE_NATIVE_CAPTURE_USABLE"); + } + } +} + /// RAII guard for `MXC_FORCE_DENY_PATHS`, mirroring [`BcUsableGuard`]. Forces /// `base_container_supports_deny_paths()` to a fixed value in tests. pub(crate) struct DenyPathsGuard { diff --git a/src/backends/learning_mode/windows/Cargo.toml b/src/backends/learning_mode/windows/Cargo.toml index 36a742bab..3b45e78b2 100644 --- a/src/backends/learning_mode/windows/Cargo.toml +++ b/src/backends/learning_mode/windows/Cargo.toml @@ -6,10 +6,10 @@ license.workspace = true [dependencies] thiserror = { workspace = true } +learning_mode_core = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] wxc_common = { workspace = true } -learning_mode_core = { workspace = true } windows = { workspace = true } windows-core = { workspace = true } diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index b1b99fdaa..bf3715357 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -24,11 +24,13 @@ //! direction; shared generic TDH primitives can be extracted later if another //! runtime consumer needs them. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::os::windows::ffi::OsStrExt; use std::path::Path; -use learning_mode_core::{AnalysisResult, AnalyzeError, DenialAnalyzer, DeniedResource}; +use learning_mode_core::{ + AnalysisResult, AnalyzeError, DenialAnalyzer, DeniedResource, ProcessLifetime, +}; use windows::core::PWSTR; use windows::Win32::System::Diagnostics::Etw::{ CloseTrace, OpenTraceW, ProcessTrace, EVENT_RECORD, EVENT_TRACE_LOGFILEW, @@ -36,6 +38,7 @@ use windows::Win32::System::Diagnostics::Etw::{ }; use crate::extractors::{extract_denial, is_learning_mode_event, DecodedEventParts, RawDenial}; +use crate::process_lifetime::{attested_process_lifetimes, JobMembershipSnapshot}; use crate::{path_norm, tdh_decode}; /// `OpenTraceW` returns this sentinel (`(TRACEHANDLE)-1`) on failure. @@ -59,10 +62,63 @@ enum CollectionMode { type RawEventVisitor<'a> = dyn FnMut(&DecodedEventParts) -> std::io::Result<()> + 'a; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LifetimeRange { + start_filetime: u64, + end_filetime: u64, +} + +#[derive(Debug, Default)] +struct ProcessLifetimeIndex { + ranges_by_pid: HashMap>, +} + +impl ProcessLifetimeIndex { + fn new(process_lifetimes: &[ProcessLifetime]) -> Self { + let mut ranges_by_pid = + HashMap::>::with_capacity(process_lifetimes.len()); + for lifetime in process_lifetimes { + ranges_by_pid + .entry(lifetime.pid) + .or_default() + .push(LifetimeRange { + start_filetime: lifetime.start_filetime, + end_filetime: lifetime.end_filetime, + }); + } + + for ranges in ranges_by_pid.values_mut() { + ranges.sort_unstable_by_key(|range| range.start_filetime); + let mut merged = Vec::::with_capacity(ranges.len()); + for range in ranges.drain(..) { + if let Some(previous) = merged.last_mut() { + if range.start_filetime <= previous.end_filetime { + previous.end_filetime = previous.end_filetime.max(range.end_filetime); + continue; + } + } + merged.push(range); + } + *ranges = merged; + } + + Self { ranges_by_pid } + } + + fn contains(&self, pid: u32, filetime: u64) -> bool { + let Some(ranges) = self.ranges_by_pid.get(&pid) else { + return false; + }; + let candidate = ranges.partition_point(|range| range.start_filetime <= filetime); + candidate > 0 && filetime <= ranges[candidate - 1].end_filetime + } +} + /// Accumulates bounded analysis results or streams raw diagnostic events /// during a `ProcessTrace` pass. struct Accumulator<'visitor> { mode: CollectionMode, + process_lifetimes: Option, denials: Vec, seen: HashSet<(String, learning_mode_core::AccessType)>, truncated: bool, @@ -80,6 +136,7 @@ impl<'visitor> Accumulator<'visitor> { fn analyze() -> Self { Self { mode: CollectionMode::Analyze, + process_lifetimes: None, denials: Vec::new(), seen: HashSet::new(), truncated: false, @@ -94,9 +151,17 @@ impl<'visitor> Accumulator<'visitor> { } } + fn analyze_for_process_lifetimes(process_lifetimes: &[ProcessLifetime]) -> Self { + Self { + process_lifetimes: Some(ProcessLifetimeIndex::new(process_lifetimes)), + ..Self::analyze() + } + } + fn raw(visitor: &'visitor mut RawEventVisitor<'visitor>) -> Self { Self { mode: CollectionMode::Raw, + process_lifetimes: None, denials: Vec::new(), seen: HashSet::new(), truncated: false, @@ -111,7 +176,23 @@ impl<'visitor> Accumulator<'visitor> { } } + /// Whether an event for `pid` at `filetime` falls within the attested + /// sandbox process lifetimes. Legacy full-trace analysis (no lifetime + /// index) treats every event as in scope. + fn in_analysis_scope(&self, pid: u32, filetime: u64) -> bool { + self.process_lifetimes + .as_ref() + .is_none_or(|lifetimes| lifetimes.contains(pid, filetime)) + } + fn add_raw_denial(&mut self, raw: RawDenial) { + if self + .process_lifetimes + .as_ref() + .is_some_and(|lifetimes| !lifetimes.contains(raw.pid, raw.filetime)) + { + return; + } let resource = if raw.resource_type == learning_mode_core::ResourceType::File { match path_norm::to_user_visible(&raw.object_name) { Some(resource) if path_norm::is_user_visible_absolute(&resource) => resource, @@ -203,6 +284,43 @@ impl<'visitor> Accumulator<'visitor> { #[derive(Debug, Default, Clone, Copy)] pub struct EtlDenialAnalyzer; +impl EtlDenialAnalyzer { + /// Analyzes only events belonging to the supplied process lifetimes. + /// + /// This is the mandatory decode path for host-wide WPR fallback traces. + /// An empty lifetime set intentionally yields an empty analysis rather than + /// exposing unscoped host events. + /// + /// # Errors + /// + /// Returns [`AnalyzeError`] if the trace cannot be opened or decoded. + pub fn analyze_for_process_lifetimes( + &self, + source_path: &Path, + process_lifetimes: &[ProcessLifetime], + ) -> Result { + let mut accumulator = Accumulator::analyze_for_process_lifetimes(process_lifetimes); + process_trace_file(source_path, &mut accumulator)?; + accumulator.into_analysis() + } + + /// Analyzes denials only for exact process generations attested by retained + /// handles belonging to the sandbox job. + /// + /// # Errors + /// + /// Returns [`AnalyzeError`] when job evidence is incomplete or inconsistent, + /// or when the trace cannot be decoded. + pub fn analyze_for_job_membership( + &self, + source_path: &Path, + membership: &JobMembershipSnapshot, + ) -> Result { + let process_lifetimes = attested_process_lifetimes(membership)?; + self.analyze_for_process_lifetimes(source_path, &process_lifetimes) + } +} + impl DenialAnalyzer for EtlDenialAnalyzer { fn analyze(&self, source_path: &Path) -> Result { let mut accumulator = Accumulator::analyze(); @@ -219,6 +337,14 @@ impl DenialAnalyzer for EtlDenialAnalyzer { /// provider manifests registered on the machine). #[cfg(test)] fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { + resources_from_events_for_process_lifetimes(events, None) +} + +#[cfg(test)] +fn resources_from_events_for_process_lifetimes( + events: &[CollectedEvent], + process_lifetimes: Option<&[ProcessLifetime]>, +) -> AnalysisResult { let mut raws = Vec::new(); for event in events { if let Some(raw) = extract_denial(&event.parts, event.pid, event.filetime) { @@ -230,7 +356,19 @@ fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { event.filetime, )); } - dedup_to_resources(raws) + let mut accumulator = match process_lifetimes { + Some(lifetimes) => Accumulator::analyze_for_process_lifetimes(lifetimes), + None => Accumulator::analyze(), + }; + for raw in raws { + accumulator.add_raw_denial(raw); + if accumulator.stop_requested { + break; + } + } + accumulator + .into_analysis() + .expect("pure denial accumulation cannot decode-fail") } /// Streams every decoded event in the ETL to `visitor` for schema discovery @@ -363,9 +501,6 @@ unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) if acc.stop_requested || acc.decode_error.is_some() || acc.panic_payload.is_some() { return; } - if !acc.begin_event() { - return; - } run_callback_guard(acc, |acc| { // SAFETY: ETW supplied a valid record, and `acc` is the live callback @@ -407,36 +542,115 @@ unsafe fn process_event_record(event_record: *mut EVENT_RECORD, acc: &mut Accumu let header = unsafe { (*event_record).EventHeader }; let provider = header.ProviderId; let event_id = header.EventDescriptor.Id; - if matches!(acc.mode, CollectionMode::Analyze) && !is_learning_mode_event(provider, event_id) { - return; - } - match unsafe { tdh_decode::decode_event_parts(event_record, &mut acc.schema_cache) } { - Ok(parts) => match acc.mode { - CollectionMode::Analyze => { - if let Some(raw) = extract_denial(&parts, header.ProcessId, header.TimeStamp as u64) - { + // Establish scope BEFORE charging the event against the shared processing + // budget. Provider, event id, PID and timestamp all live in the event + // header, so the scope test needs no (comparatively expensive) TDH decode. + // Out-of-scope host events — a foreign provider, or a PID/time outside the + // attested sandbox process lifetimes — must not consume the budget; + // otherwise a noisy host could exhaust the limit before a single in-scope + // sandbox denial is ever decoded, truncating the analysis of an innocent + // sandbox. + if matches!(acc.mode, CollectionMode::Analyze) { + if !is_learning_mode_event(provider, event_id) { + return; + } + let Some(filetime) = normalized_filetime(header.TimeStamp, acc) else { + return; + }; + if !acc.in_analysis_scope(header.ProcessId, filetime) { + return; + } + if !acc.begin_event() { + return; + } + match unsafe { tdh_decode::decode_event_parts(event_record, &mut acc.schema_cache) } { + Ok(parts) => { + if let Some(raw) = extract_denial(&parts, header.ProcessId, filetime) { acc.add_raw_denial(raw); } - for raw in crate::capability_dacl::extract_denials( - &parts, - header.ProcessId, - header.TimeStamp as u64, - ) { + for raw in + crate::capability_dacl::extract_denials(&parts, header.ProcessId, filetime) + { acc.add_raw_denial(raw); } } - CollectionMode::Raw => acc.visit_raw_event(&parts), - }, + Err(error) => acc.record_event_decode_error(provider, event_id, error), + } + return; + } + + // Raw diagnostic mode has no provider/lifetime scoping, so every decoded + // event legitimately counts against the budget. + if !acc.begin_event() { + return; + } + match unsafe { tdh_decode::decode_event_parts(event_record, &mut acc.schema_cache) } { + Ok(parts) => acc.visit_raw_event(&parts), Err(error) => acc.record_event_decode_error(provider, event_id, error), } } +fn normalized_filetime(timestamp: i64, acc: &mut Accumulator<'_>) -> Option { + // PROCESS_TRACE_MODE_RAW_TIMESTAMP is deliberately not set, so ProcessTrace + // has already converted the record timestamp to 100-nanosecond FILETIME. + match u64::try_from(timestamp) { + Ok(filetime) => Some(filetime), + Err(_) => { + acc.decode_error = Some(format!( + "ETW returned a negative normalized FILETIME timestamp ({timestamp})" + )); + None + } + } +} + #[cfg(test)] mod tests { use super::*; use learning_mode_core::{AccessType, ResourceType}; + #[test] + fn process_lifetime_index_matches_pid_and_merged_time_ranges() { + let index = ProcessLifetimeIndex::new(&[ + ProcessLifetime { + pid: 7, + start_filetime: 20, + end_filetime: 30, + }, + ProcessLifetime { + pid: 7, + start_filetime: 10, + end_filetime: 25, + }, + ProcessLifetime { + pid: 7, + start_filetime: 40, + end_filetime: 50, + }, + ProcessLifetime { + pid: 8, + start_filetime: 15, + end_filetime: 45, + }, + ]); + + assert!(index.contains(7, 10)); + assert!(index.contains(7, 30)); + assert!(!index.contains(7, 35)); + assert!(index.contains(7, 40)); + assert!(!index.contains(7, 51)); + assert!(index.contains(8, 35)); + assert!(!index.contains(9, 20)); + } + + #[test] + fn empty_process_lifetime_index_fails_closed() { + let index = ProcessLifetimeIndex::new(&[]); + + assert!(!index.contains(7, 10)); + } + #[test] fn raw_visitor_panic_is_captured_inside_callback_state() { let mut visitor = @@ -632,6 +846,27 @@ mod tests { assert!(accumulator.truncated); } + #[test] + fn out_of_scope_events_are_excluded_before_consuming_the_budget() { + // Lifetime scoping gates the shared processing budget: an event whose + // PID/time falls outside the attested sandbox lifetimes is not in + // scope, so `process_event_record` skips it before ever calling + // `begin_event`. This asserts the scope predicate that drives that + // early return; legacy (no-lifetime) analysis treats everything as in + // scope. + let scoped = Accumulator::analyze_for_process_lifetimes(&[ProcessLifetime { + pid: 7, + start_filetime: 100, + end_filetime: 200, + }]); + assert!(scoped.in_analysis_scope(7, 150)); + assert!(!scoped.in_analysis_scope(7, 250), "outside the time range"); + assert!(!scoped.in_analysis_scope(9, 150), "unrelated PID"); + + let legacy = Accumulator::analyze(); + assert!(legacy.in_analysis_scope(9, 150), "no lifetime filter"); + } + #[test] fn analyze_missing_file_returns_open_error() { let analyzer = EtlDenialAnalyzer; @@ -839,6 +1074,80 @@ mod tests { assert!(resources_from_events(&events).denials.is_empty()); } + #[test] + fn process_lifetimes_filter_unrelated_events_and_pid_reuse() { + let events = vec![ + kernel_event( + 14, + 42, + 99, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\before.txt\""), + ("AccessMask", "0x1"), + ], + ), + kernel_event( + 14, + 42, + 150, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\owned.txt\""), + ("AccessMask", "0x1"), + ], + ), + kernel_event( + 14, + 43, + 150, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\unrelated.txt\""), + ("AccessMask", "0x1"), + ], + ), + kernel_event( + 14, + 42, + 201, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\reused-pid.txt\""), + ("AccessMask", "0x1"), + ], + ), + ]; + let lifetimes = [ProcessLifetime { + pid: 42, + start_filetime: 100, + end_filetime: 200, + }]; + + let analysis = resources_from_events_for_process_lifetimes(&events, Some(&lifetimes)); + + assert_eq!(analysis.denials.len(), 1); + assert_eq!(analysis.denials[0].resource, r"C:\owned.txt"); + } + + #[test] + fn empty_process_lifetimes_fail_closed() { + let events = vec![kernel_event( + 14, + 42, + 150, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"C:\\host.txt\""), + ("AccessMask", "0x1"), + ], + )]; + + let analysis = resources_from_events_for_process_lifetimes(&events, Some(&[])); + + assert!(analysis.denials.is_empty()); + } + /// Non-actionable object types and not-denied capability records are /// dropped by the pipeline; unknown event IDs are ignored. #[test] diff --git a/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs b/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs new file mode 100644 index 000000000..cbad6dabb --- /dev/null +++ b/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bounded framing shared by guarded WPR clients and the elevated guardian. + +use std::io::{self, Read, Write}; + +const MAGIC: &[u8; 8] = b"MXCPLM01"; +const VERSION: u8 = 1; +pub const HEADER_LEN: usize = 20; +const ATTACH_HANDLES_MAGIC: &[u8; 8] = b"MXCATT01"; +pub const ATTACH_HANDLES_LEN: usize = 24; + +pub const MAX_ERROR_BYTES: u64 = 64 * 1024; +pub const MAX_TRACE_BYTES: u64 = 8 * 1024 * 1024 * 1024; +pub const MAX_ANALYSIS_BYTES: u64 = 64 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ResponseKind { + Success = 0, + Trace = 1, + Error = 2, + Stopped = 3, + Analysis = 4, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseHeader { + pub kind: ResponseKind, + pub payload_len: u64, +} + +pub fn write_header( + writer: &mut impl Write, + kind: ResponseKind, + payload_len: u64, +) -> io::Result<()> { + validate_payload(kind, payload_len)?; + let mut header = [0u8; HEADER_LEN]; + header[..8].copy_from_slice(MAGIC); + header[8] = VERSION; + header[9] = kind as u8; + header[12..20].copy_from_slice(&payload_len.to_le_bytes()); + writer.write_all(&header) +} + +pub fn read_header(reader: &mut impl Read) -> io::Result { + let mut header = [0u8; HEADER_LEN]; + reader.read_exact(&mut header)?; + if &header[..8] != MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid guarded WPR response magic", + )); + } + if header[8] != VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported guarded WPR response version", + )); + } + if header[10] != 0 || header[11] != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid guarded WPR response reserved bytes", + )); + } + let kind = match header[9] { + 0 => ResponseKind::Success, + 1 => ResponseKind::Trace, + 2 => ResponseKind::Error, + 3 => ResponseKind::Stopped, + 4 => ResponseKind::Analysis, + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid guarded WPR response kind", + )) + } + }; + let payload_len = u64::from_le_bytes( + header[12..20] + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid frame length"))?, + ); + validate_payload(kind, payload_len)?; + Ok(ResponseHeader { kind, payload_len }) +} + +fn validate_payload(kind: ResponseKind, payload_len: u64) -> io::Result<()> { + let valid = match kind { + ResponseKind::Success | ResponseKind::Stopped => payload_len == 0, + ResponseKind::Trace => payload_len <= MAX_TRACE_BYTES, + ResponseKind::Error => payload_len <= MAX_ERROR_BYTES, + ResponseKind::Analysis => payload_len <= MAX_ANALYSIS_BYTES, + }; + if valid { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid {kind:?} payload length {payload_len}"), + )) + } +} + +pub fn write_attach_handles( + writer: &mut impl Write, + job_handle: usize, + root_process_handle: usize, +) -> io::Result<()> { + validate_handle(job_handle, "job", io::ErrorKind::InvalidInput)?; + validate_handle( + root_process_handle, + "root process", + io::ErrorKind::InvalidInput, + )?; + let mut payload = [0u8; ATTACH_HANDLES_LEN]; + payload[..8].copy_from_slice(ATTACH_HANDLES_MAGIC); + payload[8..16].copy_from_slice(&(job_handle as u64).to_le_bytes()); + payload[16..24].copy_from_slice(&(root_process_handle as u64).to_le_bytes()); + writer.write_all(&payload) +} + +pub fn read_attach_handles(reader: &mut impl Read) -> io::Result<(usize, usize)> { + let mut payload = [0u8; ATTACH_HANDLES_LEN]; + reader.read_exact(&mut payload)?; + if &payload[..8] != ATTACH_HANDLES_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid guarded WPR attach-handles header", + )); + } + let job_handle = decode_handle(&payload[8..16], "job")?; + let root_process_handle = decode_handle(&payload[16..24], "root process")?; + Ok((job_handle, root_process_handle)) +} + +fn decode_handle(bytes: &[u8], name: &str) -> io::Result { + let raw = u64::from_le_bytes(bytes.try_into().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid guarded WPR {name} handle"), + ) + })?); + let handle = usize::try_from(raw).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("guarded WPR {name} handle does not fit the current architecture"), + ) + })?; + validate_handle(handle, name, io::ErrorKind::InvalidData)?; + Ok(handle) +} + +fn validate_handle(handle: usize, name: &str, kind: io::ErrorKind) -> io::Result<()> { + if handle == 0 || handle == usize::MAX { + return Err(io::Error::new( + kind, + format!("invalid guarded WPR {name} handle"), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_each_valid_header_kind() { + for expected in [ + ResponseHeader { + kind: ResponseKind::Success, + payload_len: 0, + }, + ResponseHeader { + kind: ResponseKind::Trace, + payload_len: 1234, + }, + ResponseHeader { + kind: ResponseKind::Error, + payload_len: 42, + }, + ResponseHeader { + kind: ResponseKind::Stopped, + payload_len: 0, + }, + ResponseHeader { + kind: ResponseKind::Analysis, + payload_len: 5678, + }, + ] { + let mut bytes = Vec::new(); + write_header(&mut bytes, expected.kind, expected.payload_len).unwrap(); + assert_eq!(read_header(&mut bytes.as_slice()).unwrap(), expected); + } + } + + #[test] + fn rejects_unbounded_payloads_and_success_payloads() { + assert!(write_header(&mut Vec::new(), ResponseKind::Success, 1).is_err()); + assert!(write_header(&mut Vec::new(), ResponseKind::Stopped, 1).is_err()); + assert!(write_header(&mut Vec::new(), ResponseKind::Error, MAX_ERROR_BYTES + 1).is_err()); + assert!(write_header(&mut Vec::new(), ResponseKind::Trace, MAX_TRACE_BYTES + 1).is_err()); + assert!(write_header( + &mut Vec::new(), + ResponseKind::Analysis, + MAX_ANALYSIS_BYTES + 1 + ) + .is_err()); + } + + #[test] + fn rejects_corrupt_magic_version_kind_and_reserved_bytes() { + let mut valid = Vec::new(); + write_header(&mut valid, ResponseKind::Success, 0).unwrap(); + for index in [0usize, 8, 9, 10] { + let mut corrupt = valid.clone(); + corrupt[index] = 0xff; + assert!( + read_header(&mut corrupt.as_slice()).is_err(), + "index {index}" + ); + } + } + + #[test] + fn round_trips_attach_handles() { + let mut bytes = Vec::new(); + write_attach_handles(&mut bytes, 0x1234, 0x5678).unwrap(); + assert_eq!( + read_attach_handles(&mut bytes.as_slice()).unwrap(), + (0x1234, 0x5678) + ); + } + + #[test] + fn rejects_invalid_attach_handles_and_magic() { + assert!(write_attach_handles(&mut Vec::new(), 0, 1).is_err()); + assert!(write_attach_handles(&mut Vec::new(), 1, 0).is_err()); + assert!(write_attach_handles(&mut Vec::new(), usize::MAX, 1).is_err()); + assert!(write_attach_handles(&mut Vec::new(), 1, usize::MAX).is_err()); + let mut bytes = Vec::new(); + write_attach_handles(&mut bytes, 42, 43).unwrap(); + bytes[0] ^= 0xff; + assert!(read_attach_handles(&mut bytes.as_slice()).is_err()); + } +} diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index c5d73eac7..7302a71cd 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -28,6 +28,8 @@ use thiserror::Error; +pub mod guarded_wpr_protocol; + #[cfg(target_os = "windows")] mod ffi; #[cfg(target_os = "windows")] @@ -46,6 +48,8 @@ mod extractors; #[cfg(target_os = "windows")] mod path_norm; #[cfg(target_os = "windows")] +mod process_lifetime; +#[cfg(target_os = "windows")] mod tdh_decode; #[cfg(target_os = "windows")] mod ui; @@ -59,6 +63,10 @@ pub use ffi::{is_learning_mode_api_available, LearningModeApi, LearningModeTrace #[cfg(target_os = "windows")] pub use lifecycle::CaptureSession; #[cfg(target_os = "windows")] +pub use process_lifetime::{ + JobMembershipSnapshot, JobProcessMembership, MAX_JOB_PROCESS_LIFETIMES, +}; +#[cfg(target_os = "windows")] pub use secenv::{ is_security_environment_api_available, probe_security_environment_exports, ProcessSecurityEnvironment, SecurityEnvironmentApi, SecurityEnvironmentExportReport, diff --git a/src/backends/learning_mode/windows/src/process_lifetime.rs b/src/backends/learning_mode/windows/src/process_lifetime.rs new file mode 100644 index 000000000..17b06e0b1 --- /dev/null +++ b/src/backends/learning_mode/windows/src/process_lifetime.rs @@ -0,0 +1,293 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation of OS-attested sandbox job process lifetimes. + +use std::collections::HashSet; + +use learning_mode_core::{AnalyzeError, ProcessLifetime}; + +/// Maximum number of process generations accepted for one guarded capture. +pub const MAX_JOB_PROCESS_LIFETIMES: usize = 4096; + +/// One process generation attested by a job notification and retained handle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct JobProcessMembership { + /// Process identifier reported by the job object. + pub pid: u32, + /// Exact creation time read from a retained process handle. + pub creation_filetime: u64, + /// Exact exit time read from the same retained process handle. + pub exit_filetime: u64, + /// Ordered position of `JOB_OBJECT_MSG_NEW_PROCESS`. + pub start_sequence: usize, + /// FILETIME when the guardian received the new-process message. + pub start_observed_filetime: u64, + /// Ordered position of the exit notification, when the port delivered one. + pub end_sequence: Option, + /// FILETIME when the guardian received the exit-process or active-zero message. + pub end_observed_filetime: u64, +} + +/// Bounded job membership evidence captured by the elevated guardian. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JobMembershipSnapshot { + /// Exact root generation attested from the duplicated process handle. + pub root_process: ProcessLifetime, + /// FILETIME immediately before the job was associated with the completion port. + pub attached_filetime: u64, + /// FILETIME when `JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO` was received. + pub completed_filetime: u64, + /// Kernel accounting count for all process generations ever assigned to the job. + pub total_processes: u32, + /// Number of ordered PID-bearing completion-port notifications retained. + pub notification_count: usize, + /// Completed descendant generations attested by retained process handles. + pub processes: Vec, +} + +/// Validates job evidence and returns exact handle-attested process lifetimes. +/// +/// Every descendant handle is opened from a job new-process notification and +/// verified against the duplicated sandbox job. Retaining the handle pins that +/// exact process generation even after exit, so PID reuse does not require ETL +/// lifecycle inference. +pub(crate) fn attested_process_lifetimes( + membership: &JobMembershipSnapshot, +) -> Result, AnalyzeError> { + validate_membership(membership)?; + + let mut lifetimes = Vec::with_capacity(membership.processes.len() + 1); + lifetimes.push(membership.root_process); + lifetimes.extend(membership.processes.iter().map(|process| ProcessLifetime { + pid: process.pid, + start_filetime: process.creation_filetime, + end_filetime: process.exit_filetime, + })); + lifetimes.sort_unstable_by_key(|lifetime| lifetime.start_filetime); + Ok(lifetimes) +} + +fn validate_membership(membership: &JobMembershipSnapshot) -> Result<(), AnalyzeError> { + if membership.root_process.pid == 0 + || membership.root_process.start_filetime == 0 + || membership.root_process.end_filetime < membership.root_process.start_filetime + { + return Err(AnalyzeError::Decode( + "guarded sandbox root process generation is invalid".to_string(), + )); + } + let retained_processes = membership.processes.len() + 1; + if retained_processes > MAX_JOB_PROCESS_LIFETIMES { + return Err(AnalyzeError::Decode(format!( + "guarded sandbox job exceeded the {MAX_JOB_PROCESS_LIFETIMES}-process limit" + ))); + } + if u32::try_from(retained_processes).ok() != Some(membership.total_processes) { + return Err(AnalyzeError::Decode(format!( + "guarded sandbox job accounting reported {} process generation(s), but {} unique \ + generation(s) were retained", + membership.total_processes, retained_processes + ))); + } + if membership.completed_filetime < membership.attached_filetime { + return Err(AnalyzeError::Decode( + "guarded sandbox job completion predates job attachment".to_string(), + )); + } + if membership.root_process.end_filetime > membership.completed_filetime { + return Err(AnalyzeError::Decode( + "guarded sandbox root exit follows job completion".to_string(), + )); + } + + let mut generations = HashSet::with_capacity(retained_processes); + generations.insert(( + membership.root_process.pid, + membership.root_process.start_filetime, + )); + let mut sequences = Vec::with_capacity(membership.notification_count); + for process in &membership.processes { + if process.pid == 0 + || process.creation_filetime < membership.attached_filetime + || process.exit_filetime < process.creation_filetime + || process.creation_filetime > process.start_observed_filetime + || process.exit_filetime > process.end_observed_filetime + || process.exit_filetime > membership.completed_filetime + { + return Err(AnalyzeError::Decode(format!( + "job-attested PID {} has an invalid handle-attested lifetime", + process.pid + ))); + } + if !generations.insert((process.pid, process.creation_filetime)) { + return Err(AnalyzeError::Decode(format!( + "job-attested PID {} repeats an already retained process generation", + process.pid + ))); + } + sequences.push(process.start_sequence); + if let Some(end_sequence) = process.end_sequence { + if end_sequence <= process.start_sequence { + return Err(AnalyzeError::Decode(format!( + "job-attested PID {} has an exit notification before its start notification", + process.pid + ))); + } + sequences.push(end_sequence); + } + } + sequences.sort_unstable(); + if sequences.len() != membership.notification_count { + return Err(AnalyzeError::Decode( + "guarded sandbox job notification count is inconsistent".to_string(), + )); + } + if sequences + .iter() + .copied() + .enumerate() + .any(|(expected, actual)| expected != actual) + { + return Err(AnalyzeError::Decode( + "guarded sandbox job membership notification order is incomplete".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(pid: u32, start_sequence: usize) -> JobProcessMembership { + JobProcessMembership { + pid, + creation_filetime: 110 + start_sequence as u64, + exit_filetime: 120 + start_sequence as u64, + start_sequence, + start_observed_filetime: 150 + start_sequence as u64, + end_sequence: Some(start_sequence + 1), + end_observed_filetime: 160 + start_sequence as u64, + } + } + + fn snapshot(processes: Vec) -> JobMembershipSnapshot { + JobMembershipSnapshot { + root_process: ProcessLifetime { + pid: 7, + start_filetime: 90, + end_filetime: 180, + }, + attached_filetime: 100, + completed_filetime: 200, + total_processes: (processes.len() + 1) as u32, + notification_count: processes.len() * 2, + processes, + } + } + + #[test] + fn returns_exact_handle_attested_lifetimes() { + let membership = snapshot(vec![member(42, 0)]); + + let lifetimes = + attested_process_lifetimes(&membership).expect("valid evidence should pass"); + + assert_eq!( + lifetimes, + vec![ + membership.root_process, + ProcessLifetime { + pid: 42, + start_filetime: 110, + end_filetime: 120, + }, + ] + ); + } + + #[test] + fn repeated_pid_generations_remain_distinct() { + let membership = snapshot(vec![member(42, 0), member(42, 2)]); + + let lifetimes = + attested_process_lifetimes(&membership).expect("retained handles disambiguate reuse"); + + assert_eq!(lifetimes.len(), 3); + assert_eq!(lifetimes[1].pid, 42); + assert_eq!(lifetimes[2].pid, 42); + assert_ne!(lifetimes[1].start_filetime, lifetimes[2].start_filetime); + } + + #[test] + fn duplicate_generation_identity_fails_closed() { + let first = member(42, 0); + let mut duplicate = member(42, 2); + duplicate.creation_filetime = first.creation_filetime; + duplicate.exit_filetime = first.exit_filetime; + + let error = attested_process_lifetimes(&snapshot(vec![first, duplicate])) + .expect_err("one process generation cannot satisfy two notifications"); + + assert!(error.to_string().contains("repeats")); + } + + #[test] + fn descendant_matching_root_generation_fails_closed() { + let mut process = member(7, 0); + process.creation_filetime = 90; + + attested_process_lifetimes(&snapshot(vec![process])) + .expect_err("the root generation cannot also count as a descendant"); + } + + #[test] + fn invalid_descendant_lifetime_fails_closed() { + let mut process = member(42, 0); + process.exit_filetime = process.end_observed_filetime + 1; + + let error = attested_process_lifetimes(&snapshot(vec![process])) + .expect_err("impossible handle evidence must fail"); + + assert!(error + .to_string() + .contains("invalid handle-attested lifetime")); + } + + #[test] + fn membership_limit_is_enforced() { + let processes = (0..MAX_JOB_PROCESS_LIFETIMES) + .map(|index| member(index as u32 + 1, index * 2)) + .collect(); + let membership = snapshot(processes); + + let error = + attested_process_lifetimes(&membership).expect_err("oversized membership must fail"); + + assert!(error.to_string().contains("4096")); + } + + #[test] + fn job_accounting_mismatch_fails_closed() { + let mut membership = snapshot(vec![member(42, 0)]); + membership.total_processes = 3; + + let error = attested_process_lifetimes(&membership) + .expect_err("lost process notifications must fail"); + + assert!(error.to_string().contains("accounting")); + } + + #[test] + fn incomplete_notification_order_fails_closed() { + let mut process = member(42, 0); + process.end_sequence = Some(2); + let membership = snapshot(vec![process]); + + let error = attested_process_lifetimes(&membership) + .expect_err("missing notification sequence must fail"); + + assert!(error.to_string().contains("order is incomplete")); + } +} diff --git a/src/core/learning_mode_core/src/analyze.rs b/src/core/learning_mode_core/src/analyze.rs index 473ead305..534f0f46f 100644 --- a/src/core/learning_mode_core/src/analyze.rs +++ b/src/core/learning_mode_core/src/analyze.rs @@ -13,12 +13,14 @@ use std::path::Path; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::model::DeniedResource; /// Result of decoding a capture source into bounded, de-duplicated denials. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AnalysisResult { /// Unique denials retained by the analyzer in first-seen order. pub denials: Vec, @@ -27,6 +29,31 @@ pub struct AnalysisResult { pub denied_resources_truncated: bool, } +/// Inclusive process-lifetime window used to scope a host-wide capture. +/// +/// Windows WPR fallback capture observes a host-wide provider stream. The +/// elevated analyzer accepts only denial events whose PID and timestamp fall +/// within one of these job-observed lifetimes, preventing unrelated host +/// activity and PID reuse from entering the caller's output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProcessLifetime { + /// Process identifier assigned by the OS. + pub pid: u32, + /// Process creation time in the normalized capture clock. + pub start_filetime: u64, + /// Process exit time in the normalized capture clock. + pub end_filetime: u64, +} + +impl ProcessLifetime { + /// Returns whether the event belongs to this exact process lifetime. + #[must_use] + pub fn contains(self, pid: u32, filetime: u64) -> bool { + self.pid == pid && filetime >= self.start_filetime && filetime <= self.end_filetime + } +} + impl AnalysisResult { /// Creates a complete, non-truncated result. #[must_use] @@ -118,4 +145,19 @@ mod tests { .to_string() .contains("not supported")); } + + #[test] + fn process_lifetime_is_inclusive_and_pid_specific() { + let lifetime = ProcessLifetime { + pid: 42, + start_filetime: 100, + end_filetime: 200, + }; + + assert!(lifetime.contains(42, 100)); + assert!(lifetime.contains(42, 200)); + assert!(!lifetime.contains(42, 99)); + assert!(!lifetime.contains(42, 201)); + assert!(!lifetime.contains(43, 150)); + } } diff --git a/src/core/learning_mode_core/src/lib.rs b/src/core/learning_mode_core/src/lib.rs index ccfee6bdf..b6d79508b 100644 --- a/src/core/learning_mode_core/src/lib.rs +++ b/src/core/learning_mode_core/src/lib.rs @@ -36,7 +36,7 @@ pub mod emit; pub mod model; pub mod summary; -pub use analyze::{AnalysisResult, AnalyzeError, DenialAnalyzer}; +pub use analyze::{AnalysisResult, AnalyzeError, DenialAnalyzer, ProcessLifetime}; pub use emit::{write_document, DenialsDocument, DenialsOutputPointer}; pub use model::{AccessType, DedupKey, DeniedResource, ResourceType}; pub use summary::DenialSummary; diff --git a/src/core/mxc_engine/Cargo.toml b/src/core/mxc_engine/Cargo.toml index ca32a0c8f..e53e400db 100644 --- a/src/core/mxc_engine/Cargo.toml +++ b/src/core/mxc_engine/Cargo.toml @@ -26,6 +26,15 @@ windows_sandbox_lifecycle = { workspace = true } isolation_session_common = { workspace = true, optional = true } isolation_session_bindings = { workspace = true, optional = true } wslc_common = { workspace = true, optional = true } +# Elevated PLM guardian client, used to adapt appcontainer_common's +# GuardedCaptureFactory/GuardedCaptureSession DI traits (guarded-WPR +# captureDenials fallback) without appcontainer_common depending on plm +# directly. +plm = { workspace = true } +# ProcessLifetime / AnalysisResult types shared across the guarded-capture DI +# boundary; appcontainer_common and plm already depend on this crate. +learning_mode_core = { workspace = true } +windows.workspace = true [target.'cfg(target_os = "linux")'.dependencies] bwrap_common = { workspace = true } diff --git a/src/core/mxc_engine/src/dispatch.rs b/src/core/mxc_engine/src/dispatch.rs index 2455a731c..79a4ac288 100644 --- a/src/core/mxc_engine/src/dispatch.rs +++ b/src/core/mxc_engine/src/dispatch.rs @@ -147,20 +147,27 @@ fn spawn_process_container( request: &ExecutionRequest, logger: &mut Logger, ) -> Result, MxcError> { - use appcontainer_common::dispatcher::{spawn_with_fallback, DispatchError, SpawnDispatchError}; + use appcontainer_common::dispatcher::{ + spawn_with_fallback_and_capture, DispatchError, SpawnDispatchError, + }; use std::fmt::Write; use wxc_common::sandbox_process::StdioMode; // ProcessContainer resolves to a concrete backend + isolation tier purely - // by host capability, via the shared `spawn_with_fallback` dispatcher — the - // streaming counterpart of the run-to-completion `dispatch_with_fallback` - // the executor binaries use. Both share `select_backend_with_fallback`, so - // the streaming and run-to-completion paths agree on tier selection and the - // streaming path gets the full three-tier fallback: BaseContainer (Tier 1), - // AppContainer + BFS (Tier 2), and AppContainer + DACL (Tier 3). The - // returned handle owns any DACL guard, so host-ACE restore outlives the - // child (see issue #643). - match spawn_with_fallback(request, logger, StdioMode::Pipes) { + // by host capability, via the shared `spawn_with_fallback_and_capture` + // dispatcher — the streaming counterpart of the run-to-completion + // `dispatch_with_fallback_and_capture` the executor binaries use. Both + // share `select_backend_with_fallback`, so the streaming and + // run-to-completion paths agree on tier selection and the streaming path + // gets the full three-tier fallback: BaseContainer (Tier 1), AppContainer + // + BFS (Tier 2), and AppContainer + DACL (Tier 3). The returned handle + // owns any DACL guard, so host-ACE restore outlives the child (see issue + // #643). When the request sets `captureDenials`, `factory_for_request` + // hands the guarded WPR fallback factory to the dispatcher so an + // AppContainer fallback tier can still honor it instead of failing + // closed. + let capture_factory = crate::guarded_capture::factory_for_request(request); + match spawn_with_fallback_and_capture(request, logger, StdioMode::Pipes, capture_factory) { Ok(dispatched) => { for w in &dispatched.warnings { let _ = writeln!(logger, "warning: {w}"); diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs new file mode 100644 index 000000000..2915425fd --- /dev/null +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Adapts `plm::elevated`'s guarded WPR capture protocol to +//! `appcontainer_common::guarded_capture`'s DI traits. +//! +//! `appcontainer_common` cannot depend on `plm` directly (see +//! `appcontainer_common::guarded_capture`'s module docs for the crate-layering +//! rationale). `mxc_engine` sits above both — it already has the Windows +//! backend crates as dependencies — so it owns the concrete adapter and hands +//! it to the dispatcher only for requests that actually need the fallback +//! (`request.policy.capture_denials.is_some()` on a non-native tier). + +use appcontainer_common::guarded_capture::{GuardedCaptureFactory, GuardedCaptureSession}; +use learning_mode_core::AnalysisResult; +use std::os::windows::ffi::OsStringExt; +use windows::core::PCWSTR; +use windows::Win32::Foundation::HMODULE; +use windows::Win32::System::LibraryLoader::{ + GetModuleFileNameW, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, +}; + +const GUARDIAN_CONFIRM_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); +const MAX_GUARDIAN_CONFIRM_ATTEMPTS: usize = 3; +/// Bounded per-attempt deadline for confirming that the guardian released the +/// sandbox after a discard failure. The guardian terminates promptly once a +/// discard/abandon has been requested, so this is intentionally short: without +/// it, each confirmation would inherit `plm`'s multi-minute stop timeout and a +/// three-attempt retry loop could block for tens of minutes. +const GUARDIAN_CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Timing/attempt policy for [`confirm_guardian_release_after_discard_failure`]. +/// Extracted into a struct so the confirmation timeout and retry delay are +/// injectable in tests without touching the production defaults ([`Self::default`]). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct GuardianConfirmPolicy { + max_attempts: usize, + /// Per-attempt deadline handed to each guardian-release confirmation. + confirm_timeout: std::time::Duration, + /// Delay applied between confirmation attempts. + retry_delay: std::time::Duration, +} + +impl Default for GuardianConfirmPolicy { + fn default() -> Self { + Self { + max_attempts: MAX_GUARDIAN_CONFIRM_ATTEMPTS, + confirm_timeout: GUARDIAN_CONFIRM_TIMEOUT, + retry_delay: GUARDIAN_CONFIRM_RETRY_DELAY, + } + } +} + +/// Retries guardian-release confirmation under `policy`. Each attempt calls +/// `confirm_release` with the policy's `confirm_timeout`; between failed +/// attempts it invokes `on_retry` (diagnostics) and then `sleep` with the +/// policy's `retry_delay`. Both the clock (via `confirm_timeout`) and the sleep +/// are injected so the timing is unit-testable without real waits. +fn confirm_guardian_release_after_discard_failure( + policy: GuardianConfirmPolicy, + mut confirm_release: impl FnMut(std::time::Duration) -> Result<(), String>, + mut on_retry: impl FnMut(usize, &str), + mut sleep: impl FnMut(std::time::Duration), +) -> Result<(), String> { + for attempt in 1..=policy.max_attempts { + match confirm_release(policy.confirm_timeout) { + Ok(()) => return Ok(()), + Err(error) if attempt < policy.max_attempts => { + on_retry(attempt, &error); + sleep(policy.retry_delay); + } + Err(error) => return Err(error), + } + } + unreachable!("guardian confirmation attempt range is non-empty") +} + +/// Resolve `plm.exe` next to the module containing `mxc_engine`. +/// +/// This is the executor directory for `wxc-exec.exe` and the native runtime +/// asset directory for `mxc_ffi.dll`. `current_exe()` is not sufficient for +/// library consumers because a framework-dependent .NET app reports +/// `dotnet.exe`, not the loaded MXC native module. +/// +/// Packaging and code-signing of `plm.exe` (so it ships alongside +/// `wxc-exec.exe` / `mxc_ffi.dll` in released artifacts) is delivered by #834; +/// this resolver only locates the co-located binary at runtime. +fn plm_exe_path() -> Result { + let module = module_containing_plm_resolver()?; + let dir = module + .parent() + .ok_or_else(|| "the MXC native module has no parent directory".to_string())?; + Ok(dir.join("plm.exe")) +} + +fn module_containing_plm_resolver() -> Result { + let mut module = HMODULE::default(); + let address = plm_exe_path as *const () as *const u16; + unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + PCWSTR(address), + &mut module, + ) + } + .map_err(|error| format!("failed to locate the MXC native module: {error}"))?; + + let mut path = vec![0u16; 32_768]; + let len = unsafe { GetModuleFileNameW(Some(module), &mut path) } as usize; + if len == 0 { + return Err(format!( + "failed to resolve the MXC native module path: {}", + windows::core::Error::from_thread() + )); + } + if len >= path.len() { + return Err("the MXC native module path exceeds the Windows path limit".to_string()); + } + path.truncate(len); + Ok(std::path::PathBuf::from(std::ffi::OsString::from_wide( + &path, + ))) +} + +/// [`GuardedCaptureSession`] backed by a live `plm::elevated::GuardedSession`. +struct PlmGuardedCaptureSession { + session: plm::elevated::GuardedSession, +} + +impl GuardedCaptureSession for PlmGuardedCaptureSession { + fn attach_process_tree( + &mut self, + job_handle: usize, + root_process_handle: usize, + ) -> Result<(), String> { + self.session + .attach_process_tree(job_handle, root_process_handle) + .map_err(|e| format!("guarded WPR process-tree attach failed: {e:#}")) + } + + fn discard(&mut self) -> Result<(), String> { + let discard_error = match self.session.discard() { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + match confirm_guardian_release_after_discard_failure( + GuardianConfirmPolicy::default(), + |timeout| { + self.session + .cancel_within(timeout) + .map_err(|error| format!("{error:#}")) + }, + |attempt, error| { + eprintln!( + "[mxc] guarded WPR guardian termination remains unconfirmed after \ + discard failure (attempt {attempt}/{MAX_GUARDIAN_CONFIRM_ATTEMPTS}); \ + sandbox enforcement is still active: {error}" + ); + }, + std::thread::sleep, + ) { + Ok(()) => Err(format!( + "guarded WPR discard failed: {discard_error:#}; guardian termination was \ + confirmed by the cleanup fallback" + )), + Err(error) => { + eprintln!( + "[mxc] guarded WPR guardian termination could not be confirmed after \ + {MAX_GUARDIAN_CONFIRM_ATTEMPTS} attempts; aborting to preserve sandbox \ + enforcement: {error}" + ); + std::process::abort(); + } + } + } + + fn stop_analyzed(&mut self) -> Result { + self.session + .stop_analyzed() + .map_err(|e| format!("guarded WPR stop/analyze failed: {e:#}")) + } +} + +/// [`GuardedCaptureFactory`] that starts a guarded WPR capture session via the +/// elevated `plm.exe` guardian, using [`plm::elevated::start_guarded_session_with_executable`]. +pub struct PlmGuardedCaptureFactory; + +impl GuardedCaptureFactory for PlmGuardedCaptureFactory { + fn start(&self, owner_pid: u32) -> Result, String> { + let plm_path = plm_exe_path()?; + start_with_plm_path(&plm_path, owner_pid) + } +} + +/// Start a guarded session against an explicit `plm.exe` path. Split out from +/// [`PlmGuardedCaptureFactory::start`] so unit tests can exercise the +/// missing-guardian rejection deterministically, against a synthetic path, +/// rather than depending on whether `plm.exe` happens to already exist next to +/// the current test binary in a given build/CI environment. +/// +/// Trust: co-location (via [`plm_exe_path`]) is only a *discovery* mechanism. +/// The authoritative pre-launch trust gate lives in `plm::trust` and runs +/// inside the PLM launch path immediately before `ShellExecuteExW("runas")`: +/// it verifies `plm.exe`'s Authenticode chain and Microsoft signer identity, +/// rejects a containing directory any unprivileged principal could modify, and +/// pins the file open (deny write/delete) across the launch to close the +/// check-then-launch window. An unsigned, non-Microsoft, or user-replaceable +/// `plm.exe` is refused before any elevation occurs. The existence check here +/// is just a fast, friendly pre-check. Distribution of a signed, packaged +/// `plm.exe` alongside `wxc-exec.exe` / `mxc_ffi.dll` is owned by #834; on +/// unsigned local/dev builds the trust gate deliberately refuses to elevate. +fn start_with_plm_path( + plm_path: &std::path::Path, + owner_pid: u32, +) -> Result, String> { + if !plm_path.exists() { + return Err(format!( + "plm.exe not found at {} (required for the guarded WPR captureDenials fallback)", + plm_path.display() + )); + } + let session = plm::elevated::start_guarded_session_with_executable(plm_path, owner_pid) + .map_err(|e| format!("guarded WPR session start failed: {e:#}"))?; + Ok(Box::new(PlmGuardedCaptureSession { session })) +} + +/// Build the guarded-capture factory to hand to the dispatcher for `request`, +/// or `None` when `captureDenials` isn't requested. Centralizing this (rather +/// than constructing a `PlmGuardedCaptureFactory` unconditionally at every call +/// site) keeps `run.rs` / `dispatch.rs` from wiring a factory the request never +/// needed. +pub fn factory_for_request( + request: &wxc_common::models::ExecutionRequest, +) -> Option> { + if request.policy.capture_denials.is_some() { + Some(std::sync::Arc::new(PlmGuardedCaptureFactory)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_rejects_a_nonexistent_plm_path() { + // Deterministic regardless of build/CI environment: point at a path + // that is guaranteed not to exist rather than relying on whether + // `plm.exe` happens to already sit next to the test binary. + let missing = std::env::temp_dir().join("mxc-guarded-capture-test-nonexistent-plm.exe"); + assert!(!missing.exists(), "test setup: path must not exist"); + + let error = match start_with_plm_path(&missing, std::process::id()) { + Ok(_) => panic!("a nonexistent plm.exe path must be rejected"), + Err(error) => error, + }; + assert!(error.contains("plm.exe"), "got: {error}"); + } + + #[test] + fn factory_for_request_is_none_without_capture_denials() { + let request = wxc_common::models::ExecutionRequest::default(); + assert!(factory_for_request(&request).is_none()); + } + + #[test] + fn factory_for_request_is_some_with_capture_denials() { + let mut request = wxc_common::models::ExecutionRequest::default(); + request.policy.capture_denials = Some(Default::default()); + assert!(factory_for_request(&request).is_some()); + } + + #[test] + fn resolver_locates_the_current_native_module() { + let module = module_containing_plm_resolver().expect("test module path should resolve"); + assert!(module.is_absolute()); + assert!(module.is_file()); + } + + #[test] + fn discard_failure_retries_with_backoff_until_release_is_confirmed() { + let mut attempts = 0; + let mut retries = Vec::new(); + let mut sleeps = Vec::new(); + + confirm_guardian_release_after_discard_failure( + GuardianConfirmPolicy::default(), + |_timeout| { + attempts += 1; + if attempts < 3 { + Err(format!("confirmation attempt {attempts} failed")) + } else { + Ok(()) + } + }, + |attempt, error| retries.push((attempt, error.to_string())), + |delay| sleeps.push(delay), + ) + .unwrap(); + + assert_eq!(attempts, 3); + assert_eq!( + retries, + [ + (1, "confirmation attempt 1 failed".to_string()), + (2, "confirmation attempt 2 failed".to_string()) + ] + ); + // A retry sleep happens once per failed-but-retried attempt, using the + // policy's retry delay (never a real sleep in tests). + assert_eq!( + sleeps, + [GUARDIAN_CONFIRM_RETRY_DELAY, GUARDIAN_CONFIRM_RETRY_DELAY] + ); + } + + #[test] + fn discard_failure_stops_after_bounded_confirmation_attempts() { + let mut attempts = 0; + let mut retries = Vec::new(); + let mut sleeps = Vec::new(); + + let error = confirm_guardian_release_after_discard_failure( + GuardianConfirmPolicy::default(), + |_timeout| { + attempts += 1; + Err(format!("confirmation attempt {attempts} failed")) + }, + |attempt, error| retries.push((attempt, error.to_string())), + |delay| sleeps.push(delay), + ) + .unwrap_err(); + + assert_eq!(attempts, MAX_GUARDIAN_CONFIRM_ATTEMPTS); + assert_eq!(retries.len(), MAX_GUARDIAN_CONFIRM_ATTEMPTS - 1); + assert_eq!(sleeps.len(), MAX_GUARDIAN_CONFIRM_ATTEMPTS - 1); + assert_eq!( + error, + format!("confirmation attempt {MAX_GUARDIAN_CONFIRM_ATTEMPTS} failed") + ); + } + + #[test] + fn each_guardian_confirmation_receives_the_short_bounded_timeout() { + // Every confirmation attempt must be handed the short 10s bound (not + // plm's multi-minute stop timeout), so a retry loop cannot block for + // tens of minutes. + let mut timeouts = Vec::new(); + + let _ = confirm_guardian_release_after_discard_failure( + GuardianConfirmPolicy::default(), + |timeout| { + timeouts.push(timeout); + Err("still failing".to_string()) + }, + |_attempt, _error| {}, + |_delay| {}, + ); + + assert_eq!(timeouts.len(), MAX_GUARDIAN_CONFIRM_ATTEMPTS); + assert!( + timeouts + .iter() + .all(|&timeout| timeout == GUARDIAN_CONFIRM_TIMEOUT), + "every confirmation must receive the 10s bound, got: {timeouts:?}" + ); + assert_eq!(GUARDIAN_CONFIRM_TIMEOUT, std::time::Duration::from_secs(10)); + } + + #[test] + fn confirmation_returns_ok_once_abandonment_is_confirmed() { + // A single successful confirmation short-circuits with Ok — modelling + // `cancel_within` returning Ok after the guardian is confirmed gone. No + // retries, no sleeps. + let mut attempts = 0; + let mut retries = 0; + let mut sleeps = 0; + + let result = confirm_guardian_release_after_discard_failure( + GuardianConfirmPolicy::default(), + |_timeout| { + attempts += 1; + Ok(()) + }, + |_attempt, _error| retries += 1, + |_delay| sleeps += 1, + ); + + assert!(result.is_ok()); + assert_eq!(attempts, 1); + assert_eq!(retries, 0); + assert_eq!(sleeps, 0); + } +} diff --git a/src/core/mxc_engine/src/lib.rs b/src/core/mxc_engine/src/lib.rs index 552b51cc4..e5fe40fb0 100644 --- a/src/core/mxc_engine/src/lib.rs +++ b/src/core/mxc_engine/src/lib.rs @@ -31,6 +31,8 @@ mod dispatch; mod error; +#[cfg(target_os = "windows")] +mod guarded_capture; mod platform; pub mod policy; mod probe; diff --git a/src/core/mxc_engine/src/run.rs b/src/core/mxc_engine/src/run.rs index 5cf6b6abd..51617e9aa 100644 --- a/src/core/mxc_engine/src/run.rs +++ b/src/core/mxc_engine/src/run.rs @@ -116,11 +116,18 @@ fn resolve_runner_inner( match request.containment { ContainmentBackend::ProcessContainer => { // ProcessContainer resolves to a concrete Windows backend purely by - // host capability: `dispatch_with_fallback` prefers the native - // BaseContainer (OS sandbox API) when usable and otherwise falls - // back to AppContainer tiers (BFS / DACL). The schema version does - // not influence this choice. - match appcontainer_common::dispatcher::dispatch_with_fallback(request) { + // host capability: `dispatch_with_fallback_and_capture` prefers + // the native BaseContainer (OS sandbox API) when usable and + // otherwise falls back to AppContainer tiers (BFS / DACL). The + // schema version does not influence this choice. When the request + // sets `captureDenials`, `factory_for_request` hands the guarded + // WPR fallback factory to the dispatcher so an AppContainer + // fallback tier can still honor it instead of failing closed. + let capture_factory = crate::guarded_capture::factory_for_request(request); + match appcontainer_common::dispatcher::dispatch_with_fallback_and_capture( + request, + capture_factory, + ) { Ok(dispatched) => { for w in &dispatched.warnings { let _ = writeln!(logger, "warning: {w}"); diff --git a/src/core/wxc_common/src/wire.rs b/src/core/wxc_common/src/wire.rs index 3fbbed697..ec489d19f 100644 --- a/src/core/wxc_common/src/wire.rs +++ b/src/core/wxc_common/src/wire.rs @@ -214,20 +214,21 @@ pub struct ProcessContainer { pub capabilities: Option>, /// Windows denial capture. When present, the runner records the sandboxed /// process's access attempts to a learning-mode ETL trace for later - /// inspection. Requires a host that exposes the complete official V2 - /// Learning Mode and process security-environment API set. Cannot be - /// combined with `leastPrivilege` or `network.proxy`; `filesystem.deniedPaths` - /// additionally requires the V2 deny-support capability. + /// inspection. MXC prefers native PSEC plus V2 Learning Mode when that API + /// set can fully honor the request. Otherwise it retains the highest + /// compatible legacy containment tier and uses guarded WPR capture, so + /// `leastPrivilege`, `network.proxy`, and deny-path policies can remain + /// enforced without weakening the request. pub capture_denials: Option, /// BaseProcessContainer UI settings (Windows). pub ui: Option, } /// Windows denial-capture settings. The presence of the `captureDenials` -/// object enables capture; all fields are optional. Capture is incompatible -/// with `processContainer.leastPrivilege` and `network.proxy`. Explicit -/// `filesystem.deniedPaths` requires the host's V2 process security-environment -/// support query to advertise native deny enforcement. +/// object enables capture; all fields are optional. Native capture requires +/// the complete compatible PSEC plus V2 Learning Mode API set. Requests that +/// native capture cannot represent use guarded WPR with a compatible legacy +/// SBOX or AppContainer containment tier. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index ebec37319..cb7e01789 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -22,20 +22,27 @@ serde.workspace = true chrono.workspace = true tempfile.workspace = true getrandom.workspace = true +learning_mode_windows.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true, features = [ "Win32_System_EventLog", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_IO", "Win32_System_Pipes", + "Win32_System_Diagnostics_Debug", "Win32_Storage_FileSystem", + "Win32_System_LibraryLoader", "Win32_UI_Shell", "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Security_WinTrust", ] } wxc_common = { workspace = true } learning_mode_core = { workspace = true } -learning_mode_windows = { workspace = true } [build-dependencies] mxc_build_common.workspace = true diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index ff2fd3f1a..587f4eeda 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -119,6 +119,110 @@ cargo build -p plm --target x86_64-pc-windows-msvc --release The WPR profile is embedded into `plm.exe` itself (see `src/profile_gen.rs`) and is materialized only inside the elevated child's internal temporary scratch area. `build.bat` from the repo root builds `plm.exe` and stages it next to `wxc-exec.exe` for the `--audit` integration. +## Guarded WPR `captureDenials` fallback + +Besides `--audit`, `plm.exe` also serves as the elevated **guardian** for the +`processContainer.captureDenials` legacy-tier fallback (`src/elevated.rs`). When +the native PSEC/V2 Learning Mode capture path is unavailable, MXC starts a +guarded WPR session that is scoped to the sandbox's job object and its exact +process generations, then stops/analyzes it after the sandbox exits. + +### Discovery and pre-launch trust gate + +MXC locates `plm.exe` **module-relative to the loaded MXC native binary** — the +directory that holds `wxc-exec.exe` (the executor) and `mxc_ffi.dll` (the native +asset directory used by the FFI/C# SDK). `current_exe()` is deliberately not +used, because a framework-dependent .NET host reports `dotnet.exe` rather than +the loaded MXC module. + +Co-location only *discovers* the guardian; it does not attest it. Because +`plm.exe` self-elevates, the PLM launch path enforces a **runtime trust gate** +(`src/trust.rs`) immediately before `ShellExecuteExW("runas")`, failing closed +on any of: + +1. **Authenticode trust** — `WinVerifyTrust` (generic verify-v2) must succeed + (signed, untampered, chaining to a trusted root). Revocation is checked + across the whole chain, excluding the self-signed root + (`WTD_REVOKE_WHOLECHAIN` + `WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT`). +2. **Microsoft signer identity** — the embedded PKCS#7 signer certificate's + Organization (`O`) must be `Microsoft Corporation`. This is keyed on the + organization name, not a fixed thumbprint, so it survives certificate + rollover. +3. **Directory & ancestry integrity** — every directory from the one containing + `plm.exe` (the *leaf*) up through the volume root must be **owned** by a + privileged principal (SYSTEM, Administrators, or TrustedInstaller — an owner + has implicit `WRITE_DAC`), and its DACL must not grant a non-privileged + principal dangerous rights. The masks are differentiated: the leaf rejects + any *side-load/create/replace* right (create-file/create-subdir, + delete-child, `DELETE`, `WRITE_DAC`, `WRITE_OWNER`, generic write/all); + ancestors reject only rights that let someone delete/rename/re-secure the + protected subtree (`FILE_DELETE_CHILD`, `DELETE`, `WRITE_DAC`, `WRITE_OWNER`, + `GENERIC_ALL`) — harmless "create a sibling" rights at, say, a drive root are + deliberately *not* over-rejected. Broad principals (Everyone, Authenticated + Users, BUILTIN\Users) and ordinary users are non-privileged. Inherited ACEs + are honored; inherit-only ACEs are skipped; a NULL DACL or any ACE type that + is not a standard allow/deny **fails closed**. + +To close the check-then-launch (TOCTOU) window, the gate opens `plm.exe` first +with a share mode that denies write and delete, resolves the pinned object's +stable canonical local path with `GetFinalPathNameByHandleW` (collapsing SUBST / +DOS-device / junction / symlink aliases, and rejecting UNC/remote or non-DOS +paths), and **holds that handle across `ShellExecuteExW`** while launching the +*resolved* path — never the caller's original, possibly-aliased string. +Authenticode is verified against the pinned handle itself, and the signer/ +ancestor checks all run on the resolved path. So the exact object verified is +the exact object launched: it cannot be renamed, deleted, overwritten, or +alias-substituted in between. + +**DLL side-loading.** `plm.exe` is a self-contained Rust/MSVC binary with no +private adjacent DLL dependencies (it links only system DLLs resolved from +`System32`). As defense-in-depth atop the leaf/ancestor integrity checks, the +elevated child additionally calls `SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)` +at startup so runtime `LoadLibrary` calls cannot resolve a bare DLL name to an +adjacent file. + +Runtime verification is therefore **enforced**; an unsigned, non-Microsoft, or +user-replaceable `plm.exe` is refused before any elevation. On unsigned local/ +dev builds the gate deliberately refuses to elevate. Consequently, locally +built `plm.exe` binaries cannot run guarded-WPR end-to-end scenarios; those +validations must use a signed packaged binary in a protected directory. +Producing that signed `plm.exe` alongside `wxc-exec.exe` / `mxc_ffi.dll` is +owned by **#834**. + +The same constraint applies to Rust SDK consumers. `mxc-sdk` is compiled into +the consuming executable, so module-relative discovery normally points at the +consumer's local Cargo output directory. A locally built or user-writable +adjacent `plm.exe` is intentionally rejected, which means native PSEC capture +may remain available but the guarded-WPR legacy fallback is unavailable. + +### Bounded discard-confirmation + +If discarding a guarded session fails, MXC confirms that the elevated guardian +actually released the sandbox before continuing. Each confirmation is given a +**short 10-second bound** (not `plm`'s multi-minute WPR stop timeout) and is +retried only a small, bounded number of times, so a failed discard can never +block teardown for tens of minutes. If release still cannot be confirmed after +the bounded attempts, MXC aborts to preserve sandbox enforcement rather than +proceeding with an unconfirmed live guardian. + +### Short-lived descendant attestation race + +Job completion-port notifications carry a PID, and Windows documents (see +`JOBOBJECT_ASSOCIATE_COMPLETION_PORT`) that such a PID may already refer to an +**inactive or recycled** process unless an open handle is held. The guardian +authenticates every `NEW_PROCESS` PID by opening a process **handle** and +checking `IsProcessInJob` (plus a PID re-read and a non-zero creation time) +before retaining it — a PID is never trusted on its own. + +A short-lived descendant can exit before the guardian manages to open it. That +observation race is **recorded, not fatal to the sandbox**: the running sandbox +is *never* terminated because of it. Instead, the guardian fails the capture +**analysis closed** after the sandbox has completed, and **no denials artifact +is emitted** for that run (the operator gets an explicit error rather than a +partial or mis-scoped denials report). Genuine tracker corruption (e.g. a +duplicate active-process start, or an exit with no tracked start that is not a +recorded race) still fails closed and terminates the job. + ## Limitations - **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent. diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index 0b21b7448..a28f16fe2 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -12,19 +12,29 @@ use anyhow::{Context, Result}; use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::c_void; use std::ffi::OsStr; use std::io::{Read, Write}; +use std::mem::size_of; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle}; use std::path::Path; +use std::ptr; use std::sync::atomic::AtomicIsize; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; use std::time::{Duration, Instant}; -use windows::core::PCWSTR; +use learning_mode_core::{AnalysisResult, ProcessLifetime}; +use learning_mode_windows::{ + EtlDenialAnalyzer, JobMembershipSnapshot, JobProcessMembership, MAX_JOB_PROCESS_LIFETIMES, +}; +use windows::core::{BOOL, PCWSTR}; use windows::Win32::Foundation::{ - CloseHandle, ERROR_BROKEN_PIPE, ERROR_CANCELLED, ERROR_NO_DATA, ERROR_PIPE_CONNECTED, - ERROR_PIPE_LISTENING, ERROR_PIPE_NOT_CONNECTED, HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, - WAIT_OBJECT_0, WAIT_TIMEOUT, + CloseHandle, DuplicateHandle, DUPLICATE_SAME_ACCESS, ERROR_BROKEN_PIPE, ERROR_CANCELLED, + ERROR_NO_DATA, ERROR_PIPE_CONNECTED, ERROR_PIPE_LISTENING, ERROR_PIPE_NOT_CONNECTED, FILETIME, + HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows::Win32::Security::{ GetLengthSid, GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER, @@ -32,34 +42,91 @@ use windows::Win32::Security::{ use windows::Win32::Storage::FileSystem::{ FILE_FLAGS_AND_ATTRIBUTES, FILE_FLAG_FIRST_PIPE_INSTANCE, }; +use windows::Win32::System::JobObjects::{ + IsProcessInJob, JobObjectAssociateCompletionPortInformation, + JobObjectBasicAccountingInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, JOBOBJECT_ASSOCIATE_COMPLETION_PORT, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, +}; use windows::Win32::System::Pipes::{ ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId, PeekNamedPipe, PIPE_NOWAIT, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, }; +use windows::Win32::System::SystemInformation::GetSystemTimePreciseAsFileTime; +use windows::Win32::System::SystemServices::{ + JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS, JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO, + JOB_OBJECT_MSG_EXIT_PROCESS, JOB_OBJECT_MSG_NEW_PROCESS, +}; use windows::Win32::System::Threading::{ - GetCurrentProcess, GetCurrentProcessId, GetExitCodeProcess, GetProcessId, OpenProcess, - OpenProcessToken, TerminateProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION, - PROCESS_SYNCHRONIZE, + GetCurrentProcess, GetCurrentProcessId, GetExitCodeProcess, GetProcessId, GetProcessTimes, + OpenProcess, OpenProcessToken, TerminateProcess, WaitForSingleObject, PROCESS_DUP_HANDLE, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, +}; +use windows::Win32::System::IO::{ + CreateIoCompletionPort, GetQueuedCompletionStatus, PostQueuedCompletionStatus, OVERLAPPED, }; use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; use crate::elevated_protocol::{ - read_header, write_header, ResponseKind, HEADER_LEN, MAX_ERROR_BYTES, MAX_TRACE_BYTES, + read_attach_handles, read_header, write_attach_handles, write_header, ResponseKind, + ATTACH_HANDLES_LEN, HEADER_LEN, MAX_ANALYSIS_BYTES, MAX_ERROR_BYTES, MAX_TRACE_BYTES, }; use crate::secure_scratch::{ProfileGuard, RecoveryMarker, SecureScratch}; const PIPE_PREFIX: &str = r"\\.\pipe\mxc-plm-elevated-"; const WAIT_TIMEOUT_DURATION: Duration = Duration::from_secs(10 * 60); +const ATTACH_HANDOFF_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound on how long [`JobProcessTracker::stop_worker`] waits to join the +/// job-tracker worker thread after posting the stop message. +const WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(5); +/// Upper bound on the final escalation wait. If the worker still has not +/// exited, the guardian fail-stops rather than release handles a live worker +/// might use. +const WORKER_ESCALATION_TIMEOUT: Duration = Duration::from_secs(2); const POLL_INTERVAL: Duration = Duration::from_millis(10); const TRANSFER_POLL_INTERVAL: Duration = Duration::from_millis(1); const SW_HIDE: i32 = 0; const HANDSHAKE_READY: u8 = 0xa5; const CONTROL_STOP: u8 = 1; +const CONTROL_STOP_AND_ANALYZE: u8 = 2; +const CONTROL_STOP_AND_DISCARD: u8 = 3; +const CONTROL_ATTACH_JOB: u8 = 4; +const TRACKER_STOP_MESSAGE: u32 = u32::MAX; static GUARDIAN_SINGLETON: AtomicIsize = AtomicIsize::new(0); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GuardControl { Stop, + StopAndAnalyze, + StopAndDiscard, + AttachJob, +} + +fn attest_job_process(job: HANDLE, pid: u32) -> Result<(OwnedHandle, u64)> { + let process = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + false, + pid, + ) + } + .with_context(|| format!("failed to open job process PID {pid}"))?; + let process = OwnedHandle(process); + if unsafe { GetProcessId(process.0) } != pid { + anyhow::bail!("opened process identity changed while attesting PID {pid}"); + } + let mut in_job = BOOL::default(); + unsafe { IsProcessInJob(process.0, Some(job), &mut in_job) } + .with_context(|| format!("failed to verify job membership for PID {pid}"))?; + if !in_job.as_bool() { + anyhow::bail!("PID {pid} was not a member of the guarded sandbox job"); + } + let (creation_filetime, _) = process_times(process.0) + .with_context(|| format!("failed to query creation time for PID {pid}"))?; + if creation_filetime == 0 { + anyhow::bail!("PID {pid} has an invalid creation time"); + } + Ok((process, creation_filetime)) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -72,6 +139,9 @@ enum PipeState { fn parse_guard_control(value: u8) -> Result { match value { CONTROL_STOP => Ok(GuardControl::Stop), + CONTROL_STOP_AND_ANALYZE => Ok(GuardControl::StopAndAnalyze), + CONTROL_STOP_AND_DISCARD => Ok(GuardControl::StopAndDiscard), + CONTROL_ATTACH_JOB => Ok(GuardControl::AttachJob), _ => anyhow::bail!("invalid guarded PLM control message {value}"), } } @@ -79,7 +149,9 @@ fn parse_guard_control(value: u8) -> Result { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Operation { Start, + Attach, Stop, + Discard, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -193,6 +265,7 @@ impl GuardLifecycle { struct GuardedOwner { owner: OwnedHandle, + job_tracker: Option, recovery_marker: RecoveryMarker, _singleton: SingletonGuard, lifecycle: GuardLifecycle, @@ -204,6 +277,7 @@ impl GuardedOwner { let recovery_marker = RecoveryMarker::acquire()?; let mut guarded = Self { owner, + job_tracker: None, recovery_marker, _singleton: singleton, lifecycle: GuardLifecycle::new(), @@ -258,6 +332,10 @@ impl GuardedOwner { anyhow::bail!("guarded PLM owner exited before stop"); } + if let Some(error) = self.tracker_failure()? { + return self.stop_and_discard_after_tracker_failure(pipe, error); + } + let state = match pipe_state(pipe) { Ok(state) => state, Err(error) => return Err(self.fail_after_monitor_error(error)), @@ -280,7 +358,38 @@ impl GuardedOwner { match pipe.read(&mut control) { Ok(1) => match parse_guard_control(control[0]) { Ok(GuardControl::Stop) => { - return run_guarded_stop(pipe, self); + return run_guarded_stop(pipe, self, StopDisposition::Trace); + } + Ok(GuardControl::StopAndAnalyze) => { + return run_guarded_stop(pipe, self, StopDisposition::Analyze); + } + Ok(GuardControl::StopAndDiscard) => { + return run_guarded_stop(pipe, self, StopDisposition::Discard); + } + Ok(GuardControl::AttachJob) => { + let deadline = Instant::now() + ATTACH_HANDOFF_TIMEOUT; + let handles = + read_attach_handles_polling(pipe, deadline, || self.has_exited()); + let (job_handle, root_process_handle) = match handles { + Ok(handles) => handles, + Err(error) => { + self.preserve_after_pipe_break(); + return Err(error).context( + "failed to receive guarded WPR sandbox attach handles", + ); + } + }; + let response_result = + match self.attach_process_tree(job_handle, root_process_handle) { + Ok(()) => write_header(pipe, ResponseKind::Success, 0) + .and_then(|_| pipe.flush()) + .context("failed to acknowledge guarded WPR job attachment"), + Err(error) => write_error_response(pipe, &error), + }; + if let Err(error) = response_result { + self.preserve_after_pipe_break(); + return Err(error); + } } Err(error) => { self.preserve_after_pipe_break(); @@ -305,6 +414,87 @@ impl GuardedOwner { } } + fn attach_process_tree( + &mut self, + source_job_handle: usize, + source_root_process_handle: usize, + ) -> Result<()> { + if self.job_tracker.is_some() { + anyhow::bail!("guarded WPR sandbox process tree is already attached"); + } + self.job_tracker = Some(JobProcessTracker::duplicate_and_attach( + self.owner.0, + source_job_handle, + source_root_process_handle, + )?); + Ok(()) + } + + fn finish_job_tracking(&mut self) -> Result { + self.job_tracker + .take() + .context("guarded WPR stop/analyze requires an attached sandbox process tree")? + .finish() + } + + fn tracker_failure(&self) -> Result> { + self.job_tracker + .as_ref() + .map(JobProcessTracker::failure) + .transpose() + .map(Option::flatten) + } + + fn stop_and_discard_after_tracker_failure( + &mut self, + pipe: &mut std::fs::File, + _tracker_error: String, + ) -> Result<()> { + let failure = self + .job_tracker + .take() + .context("guarded WPR tracker failure lost its process-tree state")? + .finish_failure()?; + if let Some(termination_error) = failure.termination_error { + let error = anyhow::anyhow!( + "guarded WPR process tracking failed and the sandbox job could not be terminated; \ + the trace was left for guarded recovery: {}; {termination_error}", + failure.message + ); + self.preserve_after_start_error(); + write_error_response(pipe, &error)?; + return Err(error); + } + + // The tracker worker has terminated the attested sandbox job. Stop + // retaining process handles before sealing and deleting the raw + // host-wide trace; no analysis is safe once process scoping failed. + let error = anyhow::anyhow!( + "guarded WPR process tracking failed; the sandbox job was terminated and its trace \ + was discarded: {}", + failure.message + ); + let result = (|| { + crate::wpr_path::verify_wpr_present().map_err(anyhow::Error::msg)?; + let scratch = SecureScratch::new()?; + run_monitored_wpr_stop(pipe, self, scratch.trace_path())?; + self.mark_stopped()?; + write_header(pipe, ResponseKind::Stopped, 0) + .and_then(|_| pipe.flush()) + .context("failed to return elevated PLM WPR-stopped milestone")?; + write_error_response(pipe, &error) + })(); + if let Err(stop_error) = result { + self.preserve_after_start_error(); + let combined = error.context(format!( + "additionally failed to stop and discard the guarded WPR trace: {stop_error:#}" + )); + write_error_response(pipe, &combined)?; + return Err(combined); + } + Err(error) + } + fn preserve_after_start_error(&mut self) { if self.lifecycle.abandon_started_trace() == GuardAction::Preserve { self.preserve_uncertain_trace(); @@ -340,13 +530,19 @@ impl Operation { fn as_arg(self) -> &'static str { match self { Self::Start => "start", + Self::Attach => "attach", Self::Stop => "stop", + Self::Discard => "discard", } } } struct OwnedHandle(HANDLE); +// SAFETY: Windows kernel handles are process-global. Ownership remains unique, +// and all access is synchronized by the tracker mutex when moved to its worker. +unsafe impl Send for OwnedHandle {} + impl Drop for OwnedHandle { fn drop(&mut self) { if !self.0.is_invalid() { @@ -357,6 +553,789 @@ impl Drop for OwnedHandle { } } +struct TrackedMembership { + pid: u32, + process: OwnedHandle, + creation_filetime: u64, + start_sequence: usize, + start_observed_filetime: u64, + end_sequence: Option, + end_observed_filetime: Option, +} + +struct ProcessTrackerState { + attached_filetime: u64, + root_pid: u32, + root_active: bool, + root_start_notification_seen: bool, + root_exit_notification_seen: bool, + active: HashMap, + processes: Vec, + notification_sequence: usize, + active_process_zero_filetime: Option, + error: Option, + termination_error: Option, + /// Number of `JOB_OBJECT_MSG_NEW_PROCESS` observations whose exact process + /// generation could not be authenticated before the process exited. + /// + /// Windows documents (see `JOBOBJECT_ASSOCIATE_COMPLETION_PORT`) that a PID + /// delivered on a job completion port may already refer to an inactive or + /// recycled process unless an open handle is held — which the guardian does + /// not have at notification time. A short-lived descendant can therefore + /// exit before `attest_job_process` opens it. Recording that race here — + /// instead of calling [`Self::fail`], which terminates the *running* + /// sandbox — lets a valid sandbox finish normally while the *capture + /// analysis* still fails closed at [`JobProcessTracker::finish`]. The + /// sandbox execution must never be terminated solely because of this + /// asynchronous observation race. + attestation_race_count: usize, + /// The first observed attestation race, retained for a precise diagnostic + /// without letting an adversarial flood of unauthenticated observations + /// grow memory without bound. + first_attestation_race: Option, + /// Per-PID count of unauthenticated observations still awaiting an exit + /// notification, so a delayed/backlogged descendant exit is reconciled as + /// the tail of a recorded race rather than mistaken for tracker corruption. + unattested_active: HashMap, +} + +impl ProcessTrackerState { + fn new(attached_filetime: u64, root_pid: u32) -> Self { + Self { + attached_filetime, + root_pid, + root_active: true, + root_start_notification_seen: false, + root_exit_notification_seen: false, + active: HashMap::new(), + processes: Vec::new(), + notification_sequence: 0, + active_process_zero_filetime: None, + error: None, + termination_error: None, + attestation_race_count: 0, + first_attestation_race: None, + unattested_active: HashMap::new(), + } + } + + fn fail(&mut self, message: impl Into) { + if self.error.is_none() { + self.error = Some(message.into()); + } + } + + /// Record an asynchronous descendant-attestation race without failing the + /// tracker. See [`Self::attestation_race_count`] for why this must not + /// terminate a valid, still-running sandbox. + fn record_attestation_race(&mut self, pid: u32, observed_filetime: u64, reason: String) { + self.attestation_race_count = self.attestation_race_count.saturating_add(1); + if self.first_attestation_race.is_none() { + self.first_attestation_race = Some(format!( + "PID {pid} observed at {observed_filetime}: {reason}" + )); + } + *self.unattested_active.entry(pid).or_insert(0) += 1; + } + + fn process_started(&mut self, pid: u32, observed_filetime: u64, attest: F) + where + F: FnOnce() -> Result<(OwnedHandle, u64)>, + { + self.active_process_zero_filetime = None; + if pid == self.root_pid && self.root_active && !self.root_start_notification_seen { + self.root_start_notification_seen = true; + return; + } + if self.error.is_some() { + return; + } + if self.processes.len() >= MAX_JOB_PROCESS_LIFETIMES - 1 { + self.fail(format!( + "sandbox job exceeded the {MAX_JOB_PROCESS_LIFETIMES}-process tracking limit" + )); + return; + } + if self.active.contains_key(&pid) { + self.fail(format!( + "sandbox job reported duplicate process start for PID {pid}" + )); + return; + } + let (process, creation_filetime) = match attest() { + Ok(attestation) => attestation, + Err(error) => { + // The descendant exited before the guardian could open and + // authenticate it — the completion-port PID may already be + // inactive or recycled. Failing the tracker here would call + // `TerminateJobObject` and kill a *valid, still-running* + // sandbox over an observation race. Instead, record the race + // (so `finish` fails the capture analysis closed) and let the + // sandbox continue. + self.record_attestation_race(pid, observed_filetime, format!("{error:#}")); + return; + } + }; + let index = self.processes.len(); + let start_sequence = self.take_notification_sequence(); + self.processes.push(TrackedMembership { + pid, + process, + creation_filetime, + start_sequence, + start_observed_filetime: observed_filetime, + end_sequence: None, + end_observed_filetime: None, + }); + self.active.insert(pid, index); + } + + fn process_exited(&mut self, pid: u32, observed_filetime: u64) { + if pid == self.root_pid && self.root_active && !self.root_exit_notification_seen { + self.root_active = false; + self.root_exit_notification_seen = true; + return; + } + if pid == self.root_pid + && self.root_exit_notification_seen + && !self.active.contains_key(&pid) + { + return; + } + let index = if let Some(index) = self.active.remove(&pid) { + index + } else if let Some(index) = self + .processes + .iter() + .rposition(|process| process.pid == pid && process.end_sequence.is_none()) + { + index + } else { + if self + .processes + .iter() + .any(|process| process.pid == pid && process.end_observed_filetime.is_some()) + { + return; + } + // A delayed/backlogged exit for a descendant whose start could not + // be authenticated (see `record_attestation_race`). Reconcile it as + // the tail of that recorded race rather than flagging tracker + // corruption — the race already fails the analysis closed. + if let Some(count) = self.unattested_active.get_mut(&pid) { + *count -= 1; + if *count == 0 { + self.unattested_active.remove(&pid); + } + return; + } + self.fail(format!( + "sandbox job reported process exit without a tracked start for PID {pid}" + )); + return; + }; + let end_sequence = self.take_notification_sequence(); + let process = &mut self.processes[index]; + process.end_sequence = Some(end_sequence); + process.end_observed_filetime = Some( + process + .end_observed_filetime + .map_or(observed_filetime, |active_zero| { + active_zero.min(observed_filetime) + }), + ); + } + + fn all_processes_exited(&mut self, observed_filetime: u64) { + self.root_active = false; + for (_, index) in self.active.drain() { + self.processes[index].end_observed_filetime = Some(observed_filetime); + } + self.active_process_zero_filetime = Some(observed_filetime); + } + + fn take_notification_sequence(&mut self) -> usize { + let sequence = self.notification_sequence; + self.notification_sequence += 1; + sequence + } +} + +struct JobProcessTracker { + job: OwnedHandle, + root_process: OwnedHandle, + root_pid: u32, + root_creation_filetime: u64, + completion_port: OwnedHandle, + state: Arc>, + worker: Option>, +} + +struct TrackerFailure { + message: String, + termination_error: Option, +} + +impl JobProcessTracker { + fn duplicate_and_attach( + owner: HANDLE, + source_job_handle: usize, + source_root_process_handle: usize, + ) -> Result { + let job = duplicate_owner_handle(owner, source_job_handle) + .context("failed to duplicate sandbox job from the authenticated owner")?; + let root_process = duplicate_owner_handle(owner, source_root_process_handle) + .context("failed to duplicate sandbox root process from the authenticated owner")?; + let root_pid = unsafe { GetProcessId(root_process.0) }; + if root_pid == 0 { + anyhow::bail!( + "failed to identify the sandbox root process duplicated from the authenticated owner" + ); + } + let mut in_job = BOOL::default(); + unsafe { IsProcessInJob(root_process.0, Some(job.0), &mut in_job) } + .context("failed to verify sandbox root process job membership")?; + if !in_job.as_bool() { + anyhow::bail!( + "authenticated owner's sandbox root process handle is not in the supplied job" + ); + } + let (root_creation_filetime, root_exit_filetime) = process_times(root_process.0) + .context("failed to attest sandbox root process creation time")?; + if root_creation_filetime == 0 || root_exit_filetime != 0 { + anyhow::bail!("sandbox root process was not a live process when guarded WPR attached"); + } + let completion_port = unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) } + .context("failed to create guarded WPR job completion port")?; + let completion_port = OwnedHandle(completion_port); + let association = JOBOBJECT_ASSOCIATE_COMPLETION_PORT { + CompletionKey: ptr::null_mut(), + CompletionPort: completion_port.0, + }; + let attached_filetime = current_filetime(); + unsafe { + SetInformationJobObject( + job.0, + JobObjectAssociateCompletionPortInformation, + &association as *const _ as *const c_void, + size_of::() as u32, + ) + } + .context("failed to associate the elevated guardian with the sandbox job")?; + + let state = Arc::new(Mutex::new(ProcessTrackerState::new( + attached_filetime, + root_pid, + ))); + let worker_state = Arc::clone(&state); + // The worker must own a job handle for its **entire** lifetime so that, + // even if a failed shutdown ever detached it, it could never call + // `TerminateJobObject` on a handle the tracker has already closed (or + // that the OS has since reused). Duplicate the job into an independent + // `OwnedHandle` and move it into the worker; the tracker keeps its own + // `job` handle for `finish`-time accounting queries. + let worker_job = duplicate_local_handle(job.0) + .context("failed to duplicate the sandbox job for the guarded WPR tracker worker")?; + // Give the worker its own completion-port handle as well. The tracker + // keeps the original only to post the stop message; neither side can + // close or reuse a handle value still owned by the other. + let worker_port = duplicate_local_handle(completion_port.0).context( + "failed to duplicate the completion port for the guarded WPR tracker worker", + )?; + let worker = std::thread::Builder::new() + .name("plm-job-tracker".to_string()) + .spawn(move || { + process_job_notifications(worker_port, worker_job, &worker_state); + }) + .context("failed to start guarded WPR job tracker")?; + Ok(Self { + job, + root_process, + root_pid, + root_creation_filetime, + completion_port, + state, + worker: Some(worker), + }) + } + + fn finish(mut self) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("guarded WPR job tracker state was poisoned"))?; + if (state.active_process_zero_filetime.is_some() && state.active.is_empty()) + || state.error.is_some() + { + break; + } + drop(state); + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for the sandbox job to report zero active processes" + ); + } + std::thread::sleep(POLL_INTERVAL); + } + self.stop_worker(); + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("guarded WPR job tracker state was poisoned"))?; + if !state.active.is_empty() { + anyhow::bail!( + "guarded WPR job tracker stopped with {} process(es) still active", + state.active.len() + ); + } + if let Some(error) = state.error.take() { + anyhow::bail!("{error}"); + } + if state.attestation_race_count > 0 { + // The sandbox already ran to completion (we are past + // ACTIVE_PROCESS_ZERO). We could not authenticate one or more + // descendant generations, so the capture cannot be scoped to the + // exact process lifetimes. Fail the *analysis* closed here — after + // the sandbox has finished — rather than terminating a valid run. + anyhow::bail!( + "guarded WPR observed {} sandbox descendant process(es) that exited before the \ + guardian could authenticate their identity (job completion-port PIDs may refer \ + to inactive or recycled processes); the capture could not be scoped to the exact \ + process generations and is failing closed. The sandbox itself ran to completion \ + and was not affected. First occurrence: {}", + state.attestation_race_count, + state + .first_attestation_race + .as_deref() + .unwrap_or("") + ); + } + let completed_filetime = state + .active_process_zero_filetime + .context("guarded WPR sandbox job never reported zero active processes")?; + let (_, root_exit_filetime) = process_times(self.root_process.0) + .context("failed to attest sandbox root process exit time after WPR stop")?; + if root_exit_filetime == 0 { + anyhow::bail!("sandbox root process has no kernel-attested exit time after WPR stop"); + } + if !attested_lifetime_within_bounds( + root_exit_filetime, + self.root_creation_filetime, + completed_filetime, + ) { + anyhow::bail!("sandbox root process has an invalid kernel-attested lifetime"); + } + let processes = std::mem::take(&mut state.processes) + .into_iter() + .map(|process| { + let (_, exit_filetime) = process_times(process.process.0).with_context(|| { + format!("failed to attest exit time for job process {}", process.pid) + })?; + if exit_filetime == 0 + || !attested_lifetime_within_bounds( + exit_filetime, + process.creation_filetime, + completed_filetime, + ) + { + anyhow::bail!( + "job process {} has an invalid kernel-attested lifetime", + process.pid + ); + } + Ok(JobProcessMembership { + pid: process.pid, + creation_filetime: process.creation_filetime, + exit_filetime, + start_sequence: process.start_sequence, + start_observed_filetime: process.start_observed_filetime, + end_sequence: process.end_sequence, + end_observed_filetime: process.end_observed_filetime.context(format!( + "job process {} has no exit observation time", + process.pid + ))?, + }) + }) + .collect::>>()?; + let total_processes = query_total_processes(self.job.0)?; + let retained_processes = u32::try_from(processes.len() + 1) + .context("retained sandbox process count does not fit job accounting")?; + if total_processes != retained_processes { + anyhow::bail!( + "sandbox job accounting reported {total_processes} process generation(s), but \ + guarded tracking retained {retained_processes}; completion-port notifications \ + were lost or inconsistent" + ); + } + Ok(JobMembershipSnapshot { + root_process: ProcessLifetime { + pid: self.root_pid, + start_filetime: self.root_creation_filetime, + end_filetime: root_exit_filetime, + }, + attached_filetime: state.attached_filetime, + completed_filetime, + total_processes, + notification_count: state.notification_sequence, + processes, + }) + } + + fn failure(&self) -> Result> { + self.state + .lock() + .map(|state| state.error.clone()) + .map_err(|_| anyhow::anyhow!("guarded WPR job tracker state was poisoned")) + } + + fn finish_failure(mut self) -> Result { + self.stop_worker(); + let mut state = self + .state + .lock() + .map_err(|_| anyhow::anyhow!("guarded WPR job tracker state was poisoned"))?; + Ok(TrackerFailure { + message: state + .error + .take() + .context("guarded WPR job tracker failure was not retained")?, + termination_error: state.termination_error.take(), + }) + } + + fn stop_worker(&mut self) { + let Some(worker) = self.worker.take() else { + return; + }; + // Clean stop: ask the worker to return from its blocking + // `GetQueuedCompletionStatus` *without* an error, so `finish` does not + // observe a spurious failure. + let post_result = unsafe { + PostQueuedCompletionStatus(self.completion_port.0, TRACKER_STOP_MESSAGE, 0, None) + }; + let post_succeeded = post_result.is_ok(); + if let Err(error) = post_result { + self.fail_state(format!( + "failed to signal the guarded WPR job tracker to stop: {error}" + )); + } + + let clean_join = if post_succeeded { + bounded_join( + || worker.is_finished(), + Instant::now, + std::thread::sleep, + WORKER_JOIN_TIMEOUT, + POLL_INTERVAL, + ) + } else { + // The stop message could not be queued, so skip the (pointless) + // clean wait and escalate immediately. + BoundedJoinOutcome::TimedOut + }; + + if !needs_escalation(post_succeeded, clean_join) { + if worker.join().is_err() { + self.fail_state("guarded WPR job tracker thread panicked".to_string()); + } + return; + } + + // Escalation: the worker did not return via the stop message. Do not + // close a completion-port handle from another thread: the worker may + // be processing a notification and could otherwise re-enter its wait + // with a stale/reused handle value. Both handles remain owned while we + // give the queued stop message one final bounded interval to complete. + // If the worker is truly wedged, fail-stop rather than detach or release + // either side's resources. + self.fail_state( + "guarded WPR job tracker did not stop on request; waiting one final bounded interval \ + before fail-stop" + .to_string(), + ); + + let escalation_join = bounded_join( + || worker.is_finished(), + Instant::now, + std::thread::sleep, + WORKER_ESCALATION_TIMEOUT, + POLL_INTERVAL, + ); + if escalation_requires_abort(escalation_join) { + // The worker is wedged and may still hold a raw view of resources we + // are about to drop. Releasing handles now would risk a use-after- + // free / handle-reuse, so fail-stop the guardian instead. + eprintln!( + "[plm] guarded WPR job tracker worker could not be stopped; aborting the guardian \ + to avoid releasing handles while a live worker may still use them" + ); + std::process::abort(); + } + if worker.join().is_err() { + self.fail_state("guarded WPR job tracker thread panicked".to_string()); + } + } + + /// Record a tracker failure on the shared state, recovering from a poisoned + /// lock. Centralizes the lock-and-`fail` dance used across `stop_worker`. + fn fail_state(&self, message: String) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.fail(message); + } +} + +/// Whether `stop_worker` must enter its final bounded wait after the clean-stop +/// attempt: escalate unless the stop message was posted **and** the worker then +/// finished within the initial wait. +fn needs_escalation(post_succeeded: bool, clean_join: BoundedJoinOutcome) -> bool { + !(post_succeeded && clean_join == BoundedJoinOutcome::Finished) +} + +/// Whether, after the escalation wait, `stop_worker` must fail-stop the process +/// rather than release handles: abort only if the worker still has not exited. +fn escalation_requires_abort(escalation_join: BoundedJoinOutcome) -> bool { + escalation_join == BoundedJoinOutcome::TimedOut +} + +/// Outcome of [`bounded_join`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BoundedJoinOutcome { + /// `is_finished` reported completion before the timeout elapsed. + Finished, + /// The timeout elapsed while `is_finished` still reported "not done". + TimedOut, +} + +/// Pure bounded-wait loop shared by [`JobProcessTracker::stop_worker`]. Polls +/// `is_finished` until it returns `true` or `timeout` elapses (measured via the +/// injected `now` clock), sleeping `poll_interval` between polls via the +/// injected `sleep`. Extracted with injectable clock/sleep so the timeout path +/// is unit-testable deterministically — the tests never sleep for real. +fn bounded_join( + mut is_finished: impl FnMut() -> bool, + mut now: impl FnMut() -> Instant, + mut sleep: impl FnMut(Duration), + timeout: Duration, + poll_interval: Duration, +) -> BoundedJoinOutcome { + let deadline = now() + timeout; + loop { + if is_finished() { + return BoundedJoinOutcome::Finished; + } + if now() >= deadline { + return BoundedJoinOutcome::TimedOut; + } + sleep(poll_interval); + } +} + +impl Drop for JobProcessTracker { + fn drop(&mut self) { + self.stop_worker(); + } +} + +fn duplicate_owner_handle(owner: HANDLE, source_handle: usize) -> Result { + let mut duplicated = HANDLE::default(); + unsafe { + DuplicateHandle( + owner, + HANDLE(source_handle as *mut c_void), + GetCurrentProcess(), + &mut duplicated, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + }?; + Ok(OwnedHandle(duplicated)) +} + +/// Duplicates a handle within the current process into an independent +/// `OwnedHandle`. Used to give the tracker worker its own job handle whose +/// lifetime it fully controls, so it can never operate on a handle the tracker +/// has closed. +fn duplicate_local_handle(handle: HANDLE) -> Result { + let mut duplicated = HANDLE::default(); + unsafe { + DuplicateHandle( + GetCurrentProcess(), + handle, + GetCurrentProcess(), + &mut duplicated, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + }?; + Ok(OwnedHandle(duplicated)) +} + +fn process_times(process: HANDLE) -> Result<(u64, u64)> { + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }?; + Ok((filetime_value(creation), filetime_value(exit))) +} + +/// Returns `true` when a kernel-attested `exit_filetime` is consistent with the +/// process's `creation_filetime` and the job's `completed_filetime`: it must be +/// no earlier than creation and no later than job completion. +/// +/// Extracted as a pure function (independent of the `process_times` FFI reads +/// and the system clock) so the attested-lifetime boundary conditions can be +/// exercised deterministically in unit tests without opening real process +/// handles or racing the wall clock. +fn attested_lifetime_within_bounds( + exit_filetime: u64, + creation_filetime: u64, + completed_filetime: u64, +) -> bool { + exit_filetime >= creation_filetime && exit_filetime <= completed_filetime +} + +fn query_total_processes(job: HANDLE) -> Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + unsafe { + QueryInformationJobObject( + Some(job), + JobObjectBasicAccountingInformation, + &mut accounting as *mut _ as *mut c_void, + size_of::() as u32, + None, + ) + } + .context("failed to query sandbox job process accounting")?; + Ok(accounting.TotalProcesses) +} + +fn filetime_value(value: FILETIME) -> u64 { + (u64::from(value.dwHighDateTime) << 32) | u64::from(value.dwLowDateTime) +} + +/// Drains the job's I/O completion port on the single dedicated tracker worker. +/// +/// # Invariants +/// +/// A job object is associated with exactly one completion port, and this is the +/// only thread that calls `GetQueuedCompletionStatus` on it, so **all** job +/// completion messages for the job (`JOB_OBJECT_MSG_NEW_PROCESS`, +/// `..._EXIT_PROCESS`, `..._ACTIVE_PROCESS_ZERO`) are observed **serially, in +/// kernel delivery order**, by this one worker. The recorded start/end +/// *generation order* (the notification sequence numbers) is therefore +/// consistent bookkeeping used only to reconcile membership and validate +/// ordering after the fact — it is never treated as proof of identity. +/// +/// Identity is authenticated separately and independently of the PID: each +/// `NEW_PROCESS` PID is resolved to a process **handle** and checked with +/// `IsProcessInJob` (plus a re-read of the PID and a non-zero creation time) +/// before it is retained. Because a completion-port PID may already refer to an +/// inactive or recycled process, a *failed* attestation is recorded as an +/// attestation race that fails the capture **analysis** closed at +/// [`JobProcessTracker::finish`] — it never causes an unauthenticated PID to be +/// trusted, and never terminates the running sandbox. +/// +/// The worker takes **ownership** of its own `port` and `job` handle duplicates +/// (separate `OwnedHandle`s from the tracker's) and holds them for its whole +/// lifetime. Every wait and `TerminateJobObject` call therefore acts on handles +/// it still owns — never handles the tracker has closed or the OS has reused. +/// Both handles are dropped (closed) when this function returns. +fn process_job_notifications( + port: OwnedHandle, + job: OwnedHandle, + state: &Arc>, +) { + loop { + let mut message = 0u32; + let mut completion_key = 0usize; + let mut overlapped: *mut OVERLAPPED = ptr::null_mut(); + let result = unsafe { + GetQueuedCompletionStatus( + port.0, + &mut message, + &mut completion_key, + &mut overlapped, + u32::MAX, + ) + }; + if let Err(error) = result { + fail_tracker_and_terminate_job( + job.0, + state, + format!("guarded WPR job tracker wait failed: {error}"), + ); + return; + } + if message == TRACKER_STOP_MESSAGE { + return; + } + let pid = overlapped as usize as u32; + let observed_filetime = current_filetime(); + let Ok(mut tracker_state) = state.lock() else { + return; + }; + let was_failed = tracker_state.error.is_some(); + match message { + JOB_OBJECT_MSG_NEW_PROCESS => { + tracker_state + .process_started(pid, observed_filetime, || attest_job_process(job.0, pid)); + } + JOB_OBJECT_MSG_EXIT_PROCESS | JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS => { + tracker_state.process_exited(pid, observed_filetime); + } + JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO => { + tracker_state.all_processes_exited(observed_filetime); + } + _ => {} + } + let newly_failed = !was_failed && tracker_state.error.is_some(); + drop(tracker_state); + if newly_failed { + terminate_failed_tracker_job(job.0, state); + } + } +} + +fn fail_tracker_and_terminate_job( + job: HANDLE, + state: &Arc>, + message: String, +) { + let newly_failed = match state.lock() { + Ok(mut state) => { + let newly_failed = state.error.is_none(); + state.fail(message); + newly_failed + } + Err(_) => true, + }; + if newly_failed { + terminate_failed_tracker_job(job, state); + } +} + +fn terminate_failed_tracker_job(job: HANDLE, state: &Arc>) { + if let Err(error) = unsafe { TerminateJobObject(job, u32::MAX) } { + if let Ok(mut state) = state.lock() { + let termination_error = + format!("failed to terminate sandbox job after tracker failure: {error}"); + state.termination_error = Some(termination_error); + } + } +} + +fn current_filetime() -> u64 { + filetime_value(unsafe { GetSystemTimePreciseAsFileTime() }) +} + /// Live authenticated connection to the elevated START child. /// /// [`Self::cancel`] closes an armed session and reports that WPR state may @@ -368,36 +1347,137 @@ pub struct GuardedSession { pipe: Option, process: OwnedHandle, disarmed: bool, + abandonment_report_pending: bool, } +// SAFETY: `GuardedSession` only holds a Windows process HANDLE (via +// `OwnedHandle`) and a pipe `File`, both of which are process-wide and safe to +// use from any thread — Windows HANDLEs have no thread affinity. This lets +// callers (e.g. `mxc_engine`'s `GuardedCaptureSession` adapter) store the +// session behind a `Box` DI boundary. +unsafe impl Send for GuardedSession {} + impl std::fmt::Debug for GuardedSession { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("GuardedSession") .field("connected", &self.pipe.is_some()) .field("disarmed", &self.disarmed) + .field( + "abandonment_report_pending", + &self.abandonment_report_pending, + ) .finish_non_exhaustive() } } impl GuardedSession { - pub fn cancel(&mut self) -> Result<()> { + pub fn attach_process_tree( + &mut self, + job_handle: usize, + root_process_handle: usize, + ) -> Result<()> { if self.disarmed { - return Ok(()); + anyhow::bail!("guarded PLM session is already stopped"); } - self.pipe.take(); - self.disarmed = true; - let exit_code = wait_for_child_termination(self.process.0, WAIT_TIMEOUT_DURATION).context( + let mut encoded_handles = Vec::new(); + write_attach_handles(&mut encoded_handles, job_handle, root_process_handle) + .context("failed to encode guarded WPR sandbox attach handles")?; + let mut pipe = self + .pipe + .take() + .context("guarded PLM control connection is already closed")?; + let result = pipe + .write_all(&[CONTROL_ATTACH_JOB]) + .and_then(|_| pipe.write_all(&encoded_handles)) + .and_then(|_| pipe.flush()) + .context("failed to send guarded WPR sandbox attach handles") + .and_then(|_| { + read_response( + &mut pipe, + self.process.0, + Operation::Attach, + None, + Instant::now() + WAIT_TIMEOUT_DURATION, + || Ok(()), + ) + }); + self.pipe = Some(pipe); + result + } + + pub fn cancel(&mut self) -> Result<()> { + self.cancel_within(WAIT_TIMEOUT_DURATION) + } + + /// Abandon the session, confirming guardian termination within an explicit + /// bounded `confirm_timeout` instead of the full stop timeout. + /// + /// Used by the discard-failure fallback so that repeated confirmation + /// attempts cannot each inherit the multi-minute stop timeout (which would + /// otherwise let a small retry loop block for tens of minutes). Once a + /// discard has been requested the guardian releases promptly, so a short + /// deadline is the appropriate certainty bound. + pub fn cancel_within(&mut self, confirm_timeout: Duration) -> Result<()> { + let abandoned = !self.disarmed; + if abandoned { + self.pipe.take(); + self.disarmed = true; + self.abandonment_report_pending = true; + } + let exit_code = wait_for_child_termination(self.process.0, confirm_timeout).context( "guarded PLM session could not confirm guardian termination after abandoning WPR state", )?; - eprintln!( - "[plm] guarded session ended without an explicit stop (guardian exit code \ - {exit_code}); the recovery marker was preserved and WPR state was left untouched" - ); + if self.abandonment_report_pending { + eprintln!( + "[plm] guarded session ended without an explicit stop (guardian exit code \ + {exit_code}); the recovery marker was preserved and WPR state was left untouched" + ); + self.abandonment_report_pending = false; + } Ok(()) } - pub fn stop(&mut self, trace_destination: &Path) -> Result<()> { + pub fn stop(&mut self, trace_destination: &Path) -> Result<()> { + if self.disarmed { + anyhow::bail!("guarded PLM session is already stopped"); + } + let mut pipe = self + .pipe + .take() + .context("guarded PLM control connection is already closed")?; + send_control_unless_response_pending(&mut pipe, CONTROL_STOP) + .context("failed to send guarded PLM STOP")?; + + let stopped = std::cell::Cell::new(false); + let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; + let result = read_response( + &mut pipe, + self.process.0, + Operation::Stop, + Some(trace_destination), + deadline, + || { + stopped.set(true); + Ok(()) + }, + ); + if stopped.get() { + self.disarmed = true; + drop(pipe); + let wait_result = wait_for_child_exit( + self.process.0, + deadline.saturating_duration_since(Instant::now()), + ); + result?; + wait_result + } else { + self.pipe = Some(pipe); + result + } + } + + pub fn discard(&mut self) -> Result<()> { if self.disarmed { anyhow::bail!("guarded PLM session is already stopped"); } @@ -405,17 +1485,16 @@ impl GuardedSession { .pipe .take() .context("guarded PLM control connection is already closed")?; - pipe.write_all(&[CONTROL_STOP]) - .context("failed to send guarded PLM STOP")?; - pipe.flush().context("failed to flush guarded PLM STOP")?; + send_control_unless_response_pending(&mut pipe, CONTROL_STOP_AND_DISCARD) + .context("failed to send guarded PLM discard STOP")?; let stopped = std::cell::Cell::new(false); let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; let result = read_response( &mut pipe, self.process.0, - Operation::Stop, - Some(trace_destination), + Operation::Discard, + None, deadline, || { stopped.set(true); @@ -436,6 +1515,97 @@ impl GuardedSession { result } } + + pub fn stop_analyzed(&mut self) -> Result { + if self.disarmed { + anyhow::bail!("guarded PLM session is already stopped"); + } + let mut pipe = self + .pipe + .take() + .context("guarded PLM control connection is already closed")?; + send_control_unless_response_pending(&mut pipe, CONTROL_STOP_AND_ANALYZE) + .context("failed to send guarded PLM analyzed STOP")?; + + let stopped = std::cell::Cell::new(false); + let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; + let result = read_analysis_response(&mut pipe, self.process.0, deadline, || { + stopped.set(true); + Ok(()) + }); + if stopped.get() { + self.disarmed = true; + drop(pipe); + let wait_result = wait_for_child_exit( + self.process.0, + deadline.saturating_duration_since(Instant::now()), + ); + let analysis = result?; + wait_result?; + Ok(analysis) + } else { + self.pipe = Some(pipe); + result + } + } +} + +fn send_control_unless_response_pending(pipe: &mut std::fs::File, control: u8) -> Result<()> { + if guardian_terminal_response_pending(pipe)? { + return Ok(()); + } + pipe.write_all(&[control]) + .and_then(|_| pipe.flush()) + .context("failed to write guarded PLM control byte") +} + +fn guardian_terminal_response_pending(pipe: &std::fs::File) -> Result { + match pipe_state(pipe)? { + PipeState::Empty => return Ok(false), + PipeState::Closed(error) => anyhow::bail!( + "guarded PLM control pipe closed before stop (PeekNamedPipe error {error})" + ), + PipeState::Data(_) => {} + } + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + let mut header = [0u8; HEADER_LEN]; + let mut bytes_read = 0u32; + let mut available = 0u32; + unsafe { + PeekNamedPipe( + HANDLE(pipe.as_raw_handle()), + Some(header.as_mut_ptr().cast()), + HEADER_LEN as u32, + Some(&mut bytes_read), + Some(&mut available), + None, + ) + } + .context("failed to inspect pending guarded PLM response")?; + + if bytes_read as usize >= HEADER_LEN { + let response = read_header(&mut header.as_slice()) + .context("invalid unsolicited guarded PLM response")?; + return match response.kind { + ResponseKind::Stopped | ResponseKind::Error => Ok(true), + kind => anyhow::bail!( + "unexpected unsolicited guarded PLM {kind:?} response before stop" + ), + }; + } + if available == 0 { + return Ok(false); + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for the pending guarded PLM response header \ + ({bytes_read}/{HEADER_LEN} bytes available)" + ); + } + std::thread::sleep(TRANSFER_POLL_INTERVAL); + } } impl Drop for GuardedSession { @@ -519,6 +1689,7 @@ pub fn start_guarded_session_with_executable( pipe: Some(pipe), process, disarmed: false, + abandonment_report_pending: false, }) } @@ -690,8 +1861,56 @@ fn start_owned_trace(owner: &mut GuardedOwner) -> Result<()> { } } -fn run_guarded_stop(pipe: &mut std::fs::File, owner: &mut GuardedOwner) -> Result<()> { - let result = run_guarded_stop_with_stopped(pipe, owner); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StopDisposition { + Trace, + Analyze, + Discard, +} + +fn read_attach_handles_polling( + pipe: &mut std::fs::File, + deadline: Instant, + mut owner_exited: impl FnMut() -> Result, +) -> Result<(usize, usize)> { + let mut payload = [0u8; ATTACH_HANDLES_LEN]; + let mut offset = 0; + while offset < payload.len() { + if Instant::now() >= deadline { + anyhow::bail!("timed out receiving guarded WPR sandbox attach handles"); + } + if owner_exited()? { + anyhow::bail!("guarded PLM owner exited during sandbox handle attachment"); + } + match pipe_state(pipe)? { + PipeState::Empty => std::thread::sleep(TRANSFER_POLL_INTERVAL), + PipeState::Closed(error) => anyhow::bail!( + "guarded PLM control pipe closed during sandbox handle attachment \ + (PeekNamedPipe error {error})" + ), + PipeState::Data(available) => { + let amount = (payload.len() - offset).min(available as usize); + let read = pipe + .read(&mut payload[offset..offset + amount]) + .context("failed to read guarded WPR sandbox attach handles")?; + if read == 0 { + std::thread::sleep(TRANSFER_POLL_INTERVAL); + } else { + offset += read; + } + } + } + } + read_attach_handles(&mut payload.as_slice()) + .context("invalid guarded WPR sandbox attach handles") +} + +fn run_guarded_stop( + pipe: &mut std::fs::File, + owner: &mut GuardedOwner, + disposition: StopDisposition, +) -> Result<()> { + let result = run_guarded_stop_with_stopped(pipe, owner, disposition); if let Err(error) = result { owner.preserve_after_start_error(); write_error_response(pipe, &error)?; @@ -700,7 +1919,11 @@ fn run_guarded_stop(pipe: &mut std::fs::File, owner: &mut GuardedOwner) -> Resul Ok(()) } -fn run_guarded_stop_with_stopped(pipe: &mut std::fs::File, owner: &mut GuardedOwner) -> Result<()> { +fn run_guarded_stop_with_stopped( + pipe: &mut std::fs::File, + owner: &mut GuardedOwner, + disposition: StopDisposition, +) -> Result<()> { crate::wpr_path::verify_wpr_present().map_err(anyhow::Error::msg)?; let scratch = SecureScratch::new()?; run_monitored_wpr_stop(pipe, owner, scratch.trace_path())?; @@ -708,7 +1931,16 @@ fn run_guarded_stop_with_stopped(pipe: &mut std::fs::File, owner: &mut GuardedOw write_header(pipe, ResponseKind::Stopped, 0) .and_then(|_| pipe.flush()) .context("failed to return elevated PLM WPR-stopped milestone")?; - write_trace_response(pipe, &scratch) + match disposition { + StopDisposition::Discard => write_header(pipe, ResponseKind::Success, 0) + .and_then(|_| pipe.flush()) + .context("failed to return elevated PLM discard success"), + StopDisposition::Analyze => { + let membership = owner.finish_job_tracking()?; + write_analysis_response(pipe, &scratch, &membership) + } + StopDisposition::Trace => write_trace_response(pipe, &scratch), + } } fn run_monitored_wpr_stop( @@ -777,11 +2009,33 @@ fn write_trace_response(pipe: &mut std::fs::File, scratch: &SecureScratch) -> Re MAX_TRACE_BYTES ); } + write_header(pipe, ResponseKind::Trace, len)?; copy_exact_len(&mut trace_file, pipe, len)?; pipe.flush().context("failed to flush elevated PLM trace") } +fn write_analysis_response( + pipe: &mut std::fs::File, + scratch: &SecureScratch, + membership: &JobMembershipSnapshot, +) -> Result<()> { + let analysis = EtlDenialAnalyzer + .analyze_for_job_membership(scratch.trace_path(), membership) + .context("failed to decode guarded WPR trace for the sandbox process tree")?; + let payload = + serde_json::to_vec(&analysis).context("failed to serialize guarded WPR analysis")?; + let len = payload.len() as u64; + if len > MAX_ANALYSIS_BYTES { + anyhow::bail!( + "guarded WPR analysis is {len} bytes, exceeding the {MAX_ANALYSIS_BYTES} byte limit" + ); + } + write_header(pipe, ResponseKind::Analysis, len)?; + pipe.write_all(&payload)?; + pipe.flush().context("failed to flush guarded WPR analysis") +} + fn copy_exact_len(reader: &mut impl Read, writer: &mut impl Write, len: u64) -> Result<()> { let copied = std::io::copy(&mut reader.take(len), writer) .context("failed to transfer elevated PLM trace")?; @@ -805,7 +2059,7 @@ fn authenticate_server(pipe: &std::fs::File, expected_pid: u32) -> Result anyhow::bail!("unexpected ETL payload for elevated {operation:?}"), + ResponseKind::Analysis => { + anyhow::bail!("unexpected filtered-analysis payload for elevated {operation:?}") + } ResponseKind::Error => { let mut message = vec![0u8; header.payload_len as usize]; read_exact_polling(pipe, &mut message, process, deadline)?; @@ -978,6 +2235,45 @@ fn read_response( } } +fn read_analysis_response( + pipe: &mut std::fs::File, + process: HANDLE, + deadline: Instant, + on_stopped: impl FnOnce() -> Result<()>, +) -> Result { + let first_header = read_header_polling(pipe, process, deadline)?; + let (header, stopped) = + accept_response_headers(first_header, Operation::Stop, on_stopped, || { + read_header_polling(pipe, process, deadline) + })?; + match header.kind { + ResponseKind::Analysis if stopped => { + let mut payload = vec![0u8; header.payload_len as usize]; + read_exact_polling(pipe, &mut payload, process, deadline)?; + serde_json::from_slice(&payload) + .context("elevated guarded WPR returned invalid filtered analysis") + } + ResponseKind::Analysis => { + anyhow::bail!("elevated stop returned analysis before the WPR-stopped milestone") + } + ResponseKind::Error => { + let mut message = vec![0u8; header.payload_len as usize]; + read_exact_polling(pipe, &mut message, process, deadline)?; + anyhow::bail!( + "elevated guarded WPR analysis failed: {}", + String::from_utf8_lossy(&message) + ) + } + ResponseKind::Trace => { + anyhow::bail!("elevated guarded WPR analysis returned a raw ETL payload") + } + ResponseKind::Success => { + anyhow::bail!("elevated guarded WPR analysis returned no payload") + } + ResponseKind::Stopped => unreachable!("duplicate stopped milestone handled above"), + } +} + fn accept_response_headers( first_header: crate::elevated_protocol::ResponseHeader, operation: Operation, @@ -985,7 +2281,7 @@ fn accept_response_headers( read_next: impl FnOnce() -> Result, ) -> Result<(crate::elevated_protocol::ResponseHeader, bool)> { if first_header.kind == ResponseKind::Stopped { - if operation != Operation::Stop { + if operation != Operation::Stop && operation != Operation::Discard { anyhow::bail!("unexpected WPR-stopped milestone for elevated {operation:?}"); } on_stopped().context("failed to handle PLM WPR-stopped milestone")?; @@ -1153,9 +2449,26 @@ fn launch_elevated_child( pipe_name: &str, owner_pid: Option, ) -> Result { - let working_directory = executable + // Trust gate: before elevating, prove the binary is Microsoft-signed and + // sits in a directory chain unprivileged users cannot modify, pin it open + // (deny write/delete) so it cannot be swapped before the loader maps it, + // and resolve its stable canonical path. `_integrity_guard` is held for the + // whole function — i.e. across `ShellExecuteExW` — closing the + // check-then-launch window. + let _integrity_guard = + crate::trust::verify_and_pin_launch_binary(executable).with_context(|| { + format!( + "refusing to elevate the guarded PLM binary at {}", + executable.display() + ) + })?; + // Launch the RESOLVED stable path, never the caller's original (possibly + // aliased) path — this is the path GetFinalPathNameByHandleW produced for + // the pinned object. + let launch_path = _integrity_guard.launch_path().to_path_buf(); + let working_directory = launch_path .parent() - .context("elevated PLM executable path has no parent directory")?; + .context("resolved elevated PLM executable path has no parent directory")?; let parameters = build_internal_parameters( operation, pipe_name, @@ -1163,7 +2476,7 @@ fn launch_elevated_child( owner_pid, ); let verb = to_wide("runas"); - let executable = to_wide(executable.as_os_str()); + let executable = to_wide(launch_path.as_os_str()); let parameters = to_wide(parameters); let working_directory = to_wide(working_directory.as_os_str()); let mut info = SHELLEXECUTEINFOW { @@ -1478,18 +2791,391 @@ mod tests { } #[test] - fn control_protocol_accepts_only_stop() { + fn control_protocol_accepts_only_supported_stop_modes() { assert_eq!( parse_guard_control(CONTROL_STOP).unwrap(), GuardControl::Stop ); - for invalid in [0, 2, u8::MAX] { + assert_eq!( + parse_guard_control(CONTROL_STOP_AND_ANALYZE).unwrap(), + GuardControl::StopAndAnalyze + ); + assert_eq!( + parse_guard_control(CONTROL_STOP_AND_DISCARD).unwrap(), + GuardControl::StopAndDiscard + ); + assert_eq!( + parse_guard_control(CONTROL_ATTACH_JOB).unwrap(), + GuardControl::AttachJob + ); + for invalid in [0, 5, u8::MAX] { assert!(parse_guard_control(invalid).is_err()); } } #[test] - fn empty_connected_pipe_is_not_reported_as_closed() { + fn completed_processes_count_toward_guardian_tracking_limit() { + fn fake_attestation(pid: u32) -> Result<(OwnedHandle, u64)> { + Ok((OwnedHandle(HANDLE::default()), u64::from(pid) + 1)) + } + + let mut state = ProcessTrackerState::new(1, 0); + for pid in 1..MAX_JOB_PROCESS_LIFETIMES as u32 { + state.process_started(pid, 2, || fake_attestation(pid)); + state.process_exited(pid, 3); + } + + state.process_started(u32::MAX, 4, || fake_attestation(u32::MAX)); + + assert!(state.active.is_empty()); + assert_eq!(state.processes.len(), MAX_JOB_PROCESS_LIFETIMES - 1); + assert!(state.error.as_deref().is_some_and(|error| { + error.contains("exceeded") && error.contains(&MAX_JOB_PROCESS_LIFETIMES.to_string()) + })); + } + + #[test] + fn terminal_tracker_failure_skips_further_process_attestation() { + let mut state = ProcessTrackerState::new(1, 0); + state.fail("terminal tracker failure"); + let attested = std::cell::Cell::new(false); + + state.process_started(1, 2, || { + attested.set(true); + Ok((OwnedHandle(HANDLE::default()), 2)) + }); + + assert!(!attested.get()); + assert!(state.processes.is_empty()); + assert_eq!(state.error.as_deref(), Some("terminal tracker failure")); + } + + #[test] + fn same_pid_restart_records_distinct_generations() { + // A descendant PID that starts, exits, then a *new* process reuses the + // same PID must be retained as two distinct generations, disambiguated + // by their creation times and ordered start sequences. + let mut state = ProcessTrackerState::new(100, 7); + + state.process_started(4242, 150, || Ok((OwnedHandle(HANDLE::default()), 111))); + state.process_exited(4242, 160); + state.process_started(4242, 170, || Ok((OwnedHandle(HANDLE::default()), 222))); + state.process_exited(4242, 180); + + assert!(state.error.is_none()); + assert!(state.active.is_empty()); + assert_eq!(state.processes.len(), 2); + assert_eq!(state.processes[0].pid, 4242); + assert_eq!(state.processes[1].pid, 4242); + assert_eq!(state.processes[0].creation_filetime, 111); + assert_eq!(state.processes[1].creation_filetime, 222); + assert_ne!( + state.processes[0].start_sequence, + state.processes[1].start_sequence + ); + assert!(state.processes[0].end_sequence < state.processes[1].end_sequence); + } + + #[test] + fn delayed_short_lived_descendant_race_never_fails_the_sandbox() { + // Regression for the async NEW_PROCESS OpenProcess race: a short-lived + // descendant reported on the completion port can exit before the + // guardian authenticates it. That observation race must never terminate + // the running sandbox; it is recorded and fails the *analysis* closed. + let mut state = ProcessTrackerState::new(100, 7); + + // Root start notification (the root is never opened via OpenProcess). + state.process_started(7, 150, || panic!("root generation must not be attested")); + + // The descendant has already exited by the time attestation is + // attempted, so opening it fails. + state.process_started(4242, 151, || { + anyhow::bail!("OpenProcess failed: the process has exited") + }); + + assert!( + state.error.is_none(), + "an attestation race must not fail (and thereby terminate) the sandbox" + ); + assert!(state.processes.is_empty()); + assert_eq!(state.attestation_race_count, 1); + assert_eq!(state.unattested_active.get(&4242).copied(), Some(1)); + + // A deliberately delayed / backlogged exit for that same descendant + // arrives afterwards; it must reconcile the race, not be mistaken for + // tracker corruption. + state.process_exited(4242, 160); + assert!(state.error.is_none()); + assert!(state.unattested_active.is_empty()); + + // The rest of the job completes normally. + state.process_exited(7, 170); + state.all_processes_exited(171); + + assert!( + state.error.is_none(), + "the sandbox job must never be failed because of an observation race" + ); + assert_eq!(state.attestation_race_count, 1); + assert!(state.first_attestation_race.is_some()); + } + + #[test] + fn untracked_exit_without_a_race_still_fails_closed() { + // A process exit for a PID that was never observed starting and is not + // a recorded attestation race is genuine tracker corruption and must + // still fail closed. + let mut state = ProcessTrackerState::new(100, 7); + state.process_exited(999, 160); + assert!(state + .error + .as_deref() + .is_some_and(|error| error.contains("without a tracked start"))); + } + + #[test] + fn attested_lifetime_boundaries_are_deterministic() { + let creation = 100u64; + let completed = 200u64; + // Exactly at each boundary is valid. + assert!(attested_lifetime_within_bounds( + creation, creation, completed + )); + assert!(attested_lifetime_within_bounds( + completed, creation, completed + )); + assert!(attested_lifetime_within_bounds(150, creation, completed)); + // One tick outside either boundary is invalid. + assert!(!attested_lifetime_within_bounds( + creation - 1, + creation, + completed + )); + assert!(!attested_lifetime_within_bounds( + completed + 1, + creation, + completed + )); + } + + #[test] + fn bounded_join_times_out_without_real_sleep() { + // A worker that never finishes must make `bounded_join` return + // `TimedOut` after a bounded number of polls — using an injected clock + // and sleep so the test never actually sleeps. + let base = Instant::now(); + let mut ticks = 0u64; + let mut sleeps = 0u32; + + let outcome = bounded_join( + || false, + || { + ticks += 1; + base + Duration::from_millis(ticks * 5) + }, + |_| sleeps += 1, + Duration::from_millis(20), + Duration::from_millis(5), + ); + + assert_eq!(outcome, BoundedJoinOutcome::TimedOut); + // Deadline = first now() (base+5ms) + 20ms = base+25ms. Subsequent + // now() calls advance 5ms each, so the loop terminates after a few + // polls rather than spinning forever. + assert!( + (1..=5).contains(&sleeps), + "expected a small bounded number of polls, got {sleeps}" + ); + } + + #[test] + fn bounded_join_returns_finished_when_worker_completes() { + // The worker reports "finished" on the second check; `bounded_join` + // must return `Finished` after exactly one poll, never timing out. + let base = Instant::now(); + let mut checks = 0u32; + let mut sleeps = 0u32; + + let outcome = bounded_join( + || { + checks += 1; + checks >= 2 + }, + || base, + |_| sleeps += 1, + Duration::from_secs(5), + Duration::from_millis(1), + ); + + assert_eq!(outcome, BoundedJoinOutcome::Finished); + assert_eq!(sleeps, 1, "one poll between the two liveness checks"); + } + + #[test] + fn shutdown_escalates_unless_clean_stop_finished() { + // Only a posted stop message followed by a finished worker avoids + // escalation; every other combination needs the final bounded wait. + assert!(!needs_escalation(true, BoundedJoinOutcome::Finished)); + assert!(needs_escalation(true, BoundedJoinOutcome::TimedOut)); + assert!(needs_escalation(false, BoundedJoinOutcome::Finished)); + assert!(needs_escalation(false, BoundedJoinOutcome::TimedOut)); + } + + #[test] + fn shutdown_aborts_only_when_escalation_times_out() { + // A worker that exits during the final bounded wait is joined; one that + // remains wedged forces a fail-stop rather than an unsafe detach. + assert!(!escalation_requires_abort(BoundedJoinOutcome::Finished)); + assert!(escalation_requires_abort(BoundedJoinOutcome::TimedOut)); + } + + #[test] + fn worker_job_duplicate_survives_original_close() { + use windows::Win32::System::JobObjects::CreateJobObjectW; + + // The worker holds its own job-handle duplicate. Closing the tracker's + // original handle must not invalidate the worker's — this is the + // property that makes a forced shutdown safe: the worker can still + // operate on (and terminate) the job via its own handle. + let job = OwnedHandle(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.unwrap()); + let worker_dup = duplicate_local_handle(job.0).expect("duplicate job handle"); + drop(job); + + let total = query_total_processes(worker_dup.0) + .expect("duplicated job handle remains valid after the original is closed"); + assert_eq!(total, 0); + } + + #[test] + fn worker_completion_port_duplicate_survives_original_close() { + let port = OwnedHandle( + unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }.unwrap(), + ); + let worker_dup = duplicate_local_handle(port.0).expect("duplicate completion port"); + drop(port); + + unsafe { PostQueuedCompletionStatus(worker_dup.0, TRACKER_STOP_MESSAGE, 0, None) } + .expect("post through duplicated completion-port handle"); + let mut message = 0u32; + let mut completion_key = 0usize; + let mut overlapped = ptr::null_mut(); + unsafe { + GetQueuedCompletionStatus( + worker_dup.0, + &mut message, + &mut completion_key, + &mut overlapped, + 0, + ) + } + .expect("wait through duplicated completion-port handle"); + assert_eq!(message, TRACKER_STOP_MESSAGE); + } + + #[test] + fn tracker_failure_terminates_the_attested_job() { + use windows::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW}; + + let job = OwnedHandle(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.unwrap()); + let mut child = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .unwrap(); + unsafe { AssignProcessToJobObject(job.0, HANDLE(child.as_raw_handle())) }.unwrap(); + + let state = Arc::new(Mutex::new(ProcessTrackerState::new(1, child.id()))); + fail_tracker_and_terminate_job(job.0, &state, "terminal tracker failure".to_string()); + + assert_eq!( + unsafe { WaitForSingleObject(HANDLE(child.as_raw_handle()), 5_000) }, + WAIT_OBJECT_0, + "tracker failure must terminate the sandbox job promptly" + ); + child.wait().unwrap(); + assert!(state + .lock() + .unwrap() + .error + .as_deref() + .is_some_and(|error| error.contains("terminal tracker failure"))); + } + + #[test] + fn root_notifications_are_optional_and_not_double_counted() { + for include_start in [false, true] { + let mut state = ProcessTrackerState::new(1, 42); + if include_start { + state.process_started(42, 2, || Ok((OwnedHandle(HANDLE::default()), 1))); + } + state.process_exited(42, 3); + state.all_processes_exited(4); + + assert!(state.processes.is_empty()); + assert_eq!(state.notification_sequence, 0); + assert!(state.active.is_empty()); + assert_eq!(state.active_process_zero_filetime, Some(4)); + } + } + + #[test] + fn guardian_attests_root_handle_and_reconciles_optional_root_notifications() { + use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, + }; + + let job = OwnedHandle(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.unwrap()); + let mut child = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .unwrap(); + unsafe { AssignProcessToJobObject(job.0, HANDLE(child.as_raw_handle())) }.unwrap(); + + let tracker = JobProcessTracker::duplicate_and_attach( + unsafe { GetCurrentProcess() }, + job.0 .0 as usize, + child.as_raw_handle() as usize, + ) + .unwrap(); + unsafe { TerminateJobObject(job.0, 1) }.unwrap(); + child.wait().unwrap(); + + let membership = tracker.finish().unwrap(); + assert_eq!(membership.root_process.pid, child.id()); + assert!(membership.processes.is_empty()); + assert_eq!(membership.total_processes, 1); + assert!(membership.root_process.end_filetime >= membership.root_process.start_filetime); + assert!(membership.completed_filetime >= membership.attached_filetime); + } + + #[test] + fn guardian_rejects_root_handle_from_a_different_job() { + use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, + }; + + let actual_job = OwnedHandle(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.unwrap()); + let unrelated_job = OwnedHandle(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.unwrap()); + let mut child = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "ping -n 999 127.0.0.1 >nul"]) + .spawn() + .unwrap(); + unsafe { AssignProcessToJobObject(actual_job.0, HANDLE(child.as_raw_handle())) }.unwrap(); + + let error = match JobProcessTracker::duplicate_and_attach( + unsafe { GetCurrentProcess() }, + unrelated_job.0 .0 as usize, + child.as_raw_handle() as usize, + ) { + Ok(_) => panic!("a root process from another job must be rejected"), + Err(error) => error, + }; + + assert!(error.to_string().contains("not in the supplied job")); + unsafe { TerminateJobObject(actual_job.0, 1) }.unwrap(); + child.wait().unwrap(); + } + + fn connected_pipe_pair() -> (std::fs::File, std::fs::File) { let pipe_name = new_pipe_name().unwrap(); let server = OwnedHandle(create_pipe(&pipe_name).unwrap()); let client_name = pipe_name.clone(); @@ -1521,12 +3207,18 @@ mod tests { } } - let mut client = client_thread.join().unwrap(); - assert_eq!(pipe_state(&client).unwrap(), PipeState::Empty); - + let client = client_thread.join().unwrap(); let raw = server.0 .0; std::mem::forget(server); - let mut server_file = unsafe { std::fs::File::from_raw_handle(raw) }; + let server_file = unsafe { std::fs::File::from_raw_handle(raw) }; + (client, server_file) + } + + #[test] + fn empty_connected_pipe_is_not_reported_as_closed() { + let (mut client, mut server_file) = connected_pipe_pair(); + assert_eq!(pipe_state(&client).unwrap(), PipeState::Empty); + server_file.write_all(&[CONTROL_STOP]).unwrap(); server_file.flush().unwrap(); assert_eq!(pipe_state(&client).unwrap(), PipeState::Data(1)); @@ -1545,6 +3237,62 @@ mod tests { panic!("closed test pipe remained connected"); } + #[test] + fn pending_guardian_response_suppresses_a_new_control_byte() { + let (mut client, mut server) = connected_pipe_pair(); + write_header(&mut server, ResponseKind::Stopped, 0).unwrap(); + server.flush().unwrap(); + + send_control_unless_response_pending(&mut client, CONTROL_STOP_AND_ANALYZE).unwrap(); + + assert_eq!(pipe_state(&server).unwrap(), PipeState::Empty); + assert_eq!( + read_header(&mut client).unwrap().kind, + ResponseKind::Stopped + ); + } + + #[test] + fn unrelated_pending_response_does_not_suppress_a_control_byte() { + let (mut client, mut server) = connected_pipe_pair(); + write_header(&mut server, ResponseKind::Success, 0).unwrap(); + server.flush().unwrap(); + + let error = send_control_unless_response_pending(&mut client, CONTROL_STOP_AND_ANALYZE) + .unwrap_err(); + + assert!(error.to_string().contains("unexpected unsolicited")); + assert_eq!(pipe_state(&server).unwrap(), PipeState::Empty); + } + + #[test] + fn empty_guardian_pipe_receives_the_requested_control_byte() { + let (mut client, mut server) = connected_pipe_pair(); + + send_control_unless_response_pending(&mut client, CONTROL_STOP_AND_ANALYZE).unwrap(); + + let mut control = [0u8; 1]; + server.read_exact(&mut control).unwrap(); + assert_eq!(control[0], CONTROL_STOP_AND_ANALYZE); + } + + #[test] + fn partial_attach_payload_times_out_without_blocking() { + let (mut client, mut server_file) = connected_pipe_pair(); + server_file.write_all(b"MXCATT01").unwrap(); + server_file.flush().unwrap(); + + let started = Instant::now(); + let error = + read_attach_handles_polling(&mut client, started + Duration::from_millis(25), || { + Ok(false) + }) + .expect_err("partial attach payload must time out"); + + assert!(error.to_string().contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + fn armed_lifecycle() -> GuardLifecycle { let mut lifecycle = GuardLifecycle::new(); assert_eq!(lifecycle.ready(true), GuardAction::None); @@ -1601,14 +3349,44 @@ mod tests { pipe: None, process: OwnedHandle(handle), disarmed: false, + abandonment_report_pending: false, }; session.cancel().unwrap(); assert!(session.disarmed); + assert!(!session.abandonment_report_pending); assert_eq!(child.wait().unwrap().code(), Some(expected_exit_code)); } } + #[test] + fn cancel_confirms_guardian_exit_and_flushes_pending_abandonment_report() { + let mut child = std::process::Command::new("cmd.exe") + .args(["/d", "/c", "ping.exe -n 2 127.0.0.1 >nul"]) + .spawn() + .unwrap(); + assert!(child.try_wait().unwrap().is_none()); + let handle = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + child.id(), + ) + } + .unwrap(); + let mut session = GuardedSession { + pipe: None, + process: OwnedHandle(handle), + disarmed: true, + abandonment_report_pending: true, + }; + + session.cancel().unwrap(); + + assert!(child.try_wait().unwrap().is_some()); + assert!(!session.abandonment_report_pending); + } + #[test] fn guarded_start_command_contains_no_filesystem_path_argument() { let pipe = r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff"; diff --git a/src/host/plm/src/elevated_protocol.rs b/src/host/plm/src/elevated_protocol.rs index bdd2c760c..c80ad92c0 100644 --- a/src/host/plm/src/elevated_protocol.rs +++ b/src/host/plm/src/elevated_protocol.rs @@ -1,153 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Framing shared by the unelevated PLM parent and its restricted elevated child. +//! Compatibility re-export for PLM's guarded WPR protocol. -use std::io::{self, Read, Write}; - -const MAGIC: &[u8; 8] = b"MXCPLM01"; -const VERSION: u8 = 1; -pub const HEADER_LEN: usize = 20; - -pub const MAX_ERROR_BYTES: u64 = 64 * 1024; -pub const MAX_TRACE_BYTES: u64 = 8 * 1024 * 1024 * 1024; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u8)] -pub enum ResponseKind { - Success = 0, - Trace = 1, - Error = 2, - Stopped = 3, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ResponseHeader { - pub kind: ResponseKind, - pub payload_len: u64, -} - -pub fn write_header( - writer: &mut impl Write, - kind: ResponseKind, - payload_len: u64, -) -> io::Result<()> { - validate_payload(kind, payload_len)?; - let mut header = [0u8; HEADER_LEN]; - header[..8].copy_from_slice(MAGIC); - header[8] = VERSION; - header[9] = kind as u8; - header[12..20].copy_from_slice(&payload_len.to_le_bytes()); - writer.write_all(&header) -} - -pub fn read_header(reader: &mut impl Read) -> io::Result { - let mut header = [0u8; HEADER_LEN]; - reader.read_exact(&mut header)?; - if &header[..8] != MAGIC { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid PLM elevated-response magic", - )); - } - if header[8] != VERSION { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported PLM elevated-response version", - )); - } - if header[10] != 0 || header[11] != 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid PLM elevated-response reserved bytes", - )); - } - let kind = match header[9] { - 0 => ResponseKind::Success, - 1 => ResponseKind::Trace, - 2 => ResponseKind::Error, - 3 => ResponseKind::Stopped, - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid PLM elevated-response kind", - )) - } - }; - let payload_len = u64::from_le_bytes( - header[12..20] - .try_into() - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid frame length"))?, - ); - validate_payload(kind, payload_len)?; - Ok(ResponseHeader { kind, payload_len }) -} - -fn validate_payload(kind: ResponseKind, payload_len: u64) -> io::Result<()> { - let valid = match kind { - ResponseKind::Success | ResponseKind::Stopped => payload_len == 0, - ResponseKind::Trace => payload_len <= MAX_TRACE_BYTES, - ResponseKind::Error => payload_len <= MAX_ERROR_BYTES, - }; - if valid { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid {kind:?} payload length {payload_len}"), - )) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_each_valid_header_kind() { - for expected in [ - ResponseHeader { - kind: ResponseKind::Success, - payload_len: 0, - }, - ResponseHeader { - kind: ResponseKind::Trace, - payload_len: 1234, - }, - ResponseHeader { - kind: ResponseKind::Error, - payload_len: 42, - }, - ResponseHeader { - kind: ResponseKind::Stopped, - payload_len: 0, - }, - ] { - let mut bytes = Vec::new(); - write_header(&mut bytes, expected.kind, expected.payload_len).unwrap(); - assert_eq!(read_header(&mut bytes.as_slice()).unwrap(), expected); - } - } - - #[test] - fn rejects_unbounded_payloads_and_success_payloads() { - assert!(write_header(&mut Vec::new(), ResponseKind::Success, 1).is_err()); - assert!(write_header(&mut Vec::new(), ResponseKind::Stopped, 1).is_err()); - assert!(write_header(&mut Vec::new(), ResponseKind::Error, MAX_ERROR_BYTES + 1).is_err()); - assert!(write_header(&mut Vec::new(), ResponseKind::Trace, MAX_TRACE_BYTES + 1).is_err()); - } - - #[test] - fn rejects_corrupt_magic_version_kind_and_reserved_bytes() { - let mut valid = Vec::new(); - write_header(&mut valid, ResponseKind::Success, 0).unwrap(); - for index in [0usize, 8, 9, 10] { - let mut corrupt = valid.clone(); - corrupt[index] = 0xff; - assert!( - read_header(&mut corrupt.as_slice()).is_err(), - "index {index}" - ); - } - } -} +pub use learning_mode_windows::guarded_wpr_protocol::*; diff --git a/src/host/plm/src/lib.rs b/src/host/plm/src/lib.rs index 1573fedcb..aae3f8cb1 100644 --- a/src/host/plm/src/lib.rs +++ b/src/host/plm/src/lib.rs @@ -28,5 +28,8 @@ pub mod start; #[cfg(target_os = "windows")] pub mod stop; +#[cfg(target_os = "windows")] +pub mod trust; + #[cfg(target_os = "windows")] pub mod wpr_path; diff --git a/src/host/plm/src/main.rs b/src/host/plm/src/main.rs index c7b98eae4..79ac2f2b3 100644 --- a/src/host/plm/src/main.rs +++ b/src/host/plm/src/main.rs @@ -105,6 +105,11 @@ fn exe_dir() -> Result { #[cfg(target_os = "windows")] fn internal_operation(operation: InternalOperation) -> Result<()> { + // Harden the elevated child's DLL search order before doing any work, so a + // runtime LoadLibrary cannot side-load an adjacent DLL. The install + // directory is also verified non-user-writable by the launcher's trust + // gate; this is defense-in-depth. + plm::trust::harden_dll_search_path()?; match operation { InternalOperation::Start { pipe_name, diff --git a/src/host/plm/src/trust.rs b/src/host/plm/src/trust.rs new file mode 100644 index 000000000..d7080442e --- /dev/null +++ b/src/host/plm/src/trust.rs @@ -0,0 +1,1350 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pre-launch trust gate for the elevated `plm.exe` guardian. +//! +//! `plm.exe` self-elevates via `ShellExecuteExW("runas")`, so before that +//! launch we must prove the binary about to run as administrator is genuinely +//! Microsoft's and cannot be swapped underneath us. This module enforces, and +//! fails closed on, **all** of: +//! +//! 1. **Authenticode trust** — [`WinVerifyTrust`] with the generic +//! verify-v2 policy (chains to a trusted root, honoring revocation policy). +//! 2. **Microsoft signer identity** — the embedded PKCS#7 signer certificate's +//! Organization (`O`) name must be Microsoft. This is deliberately keyed on +//! the organization name rather than a fixed thumbprint so it survives +//! certificate rollover. +//! 3. **Directory integrity** — the containing directory's DACL must not grant +//! any non-privileged principal rights that would let them replace the +//! binary (create/delete files, delete-child, `WRITE_DAC`, `WRITE_OWNER`, +//! generic write/all). Only SYSTEM, Administrators, and TrustedInstaller may +//! hold such rights. +//! +//! To close the check-then-launch (TOCTOU) window, [`verify_and_pin_launch_binary`] +//! opens the file **first** with a share mode that denies write and delete, and +//! returns a [`LaunchIntegrityGuard`] that keeps that handle open. The caller +//! holds the guard across `ShellExecuteExW`, so the exact bytes verified are the +//! bytes the loader maps — the file cannot be renamed, deleted, or overwritten +//! in between. +//! +//! The signer/ACL *classification* is factored into pure functions so it is +//! unit-testable without a locally signed binary. + +use anyhow::{bail, Context, Result}; +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::ptr; + +use windows::core::{Error as WinError, PCSTR, PCWSTR, PWSTR}; +use windows::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL, HWND}; +use windows::Win32::Security::Authorization::{ + ConvertSidToStringSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, +}; +use windows::Win32::Security::Cryptography::{ + CertCloseStore, CertFindCertificateInStore, CertFreeCertificateContext, CertGetNameStringW, + CryptMsgClose, CryptMsgGetParam, CryptQueryObject, CERT_CONTEXT, CERT_FIND_SUBJECT_CERT, + CERT_INFO, CERT_NAME_ATTR_TYPE, CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_ENCODING_TYPE, CERT_QUERY_FORMAT_FLAG_BINARY, CERT_QUERY_OBJECT_FILE, + CMSG_SIGNER_INFO, CMSG_SIGNER_INFO_PARAM, CRYPT_INTEGER_BLOB, HCERTSTORE, PKCS_7_ASN_ENCODING, + X509_ASN_ENCODING, +}; +use windows::Win32::Security::WinTrust::{ + WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, + WINTRUST_DATA_PROVIDER_FLAGS, WINTRUST_DATA_REVOCATION_CHECKS, WINTRUST_FILE_INFO, + WTD_CHOICE_FILE, WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT, WTD_REVOKE_WHOLECHAIN, + WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, WTD_UI_NONE, +}; +use windows::Win32::Security::{ + AclSizeInformation, GetAclInformation, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, + ACL_SIZE_INFORMATION, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, +}; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, GetFinalPathNameByHandleW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, + FILE_NAME_NORMALIZED, FILE_SHARE_READ, GETFINALPATHNAMEBYHANDLE_FLAGS, OPEN_EXISTING, + VOLUME_NAME_DOS, +}; +use windows::Win32::System::LibraryLoader::{ + SetDefaultDllDirectories, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows::Win32::System::{Diagnostics::Debug::ReadProcessMemory, Threading::GetCurrentProcess}; + +/// Authenticode revocation policy: check revocation across the whole chain, but +/// exclude the (self-signed) root — the standard, network-robust policy used by +/// signing tools. Kept as named constants so code and docs stay consistent. +/// +/// This is an intentional **fail-closed trust posture**, not a correctness +/// tweak: if revocation status cannot be determined (offline, no cached +/// CRL/OCSP, or an unreachable responder), `WinVerifyTrust` returns a non-zero +/// status and the launch is refused. Signed end-to-end runs therefore require +/// revocation availability or a valid cached revocation status; unknown +/// revocation is never silently accepted. +const REVOCATION_CHECKS: WINTRUST_DATA_REVOCATION_CHECKS = WTD_REVOKE_WHOLECHAIN; +const REVOCATION_PROVIDER_FLAGS: WINTRUST_DATA_PROVIDER_FLAGS = + WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT; + +/// `INHERIT_ONLY_ACE` — the ACE does not apply to the object itself, only to +/// children, so it does not affect who can modify this directory. +const INHERIT_ONLY_ACE: u8 = 0x08; +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00; +const ACCESS_DENIED_ACE_TYPE: u8 = 0x01; + +// Access-mask bits relevant to replacing or side-loading around `plm.exe`. +const FILE_ADD_FILE: u32 = 0x0002; // create a file in the directory +const FILE_ADD_SUBDIRECTORY: u32 = 0x0004; // create a subdirectory +const FILE_DELETE_CHILD: u32 = 0x0040; // delete/rename an entry in the directory +const DELETE: u32 = 0x0001_0000; // delete/rename this directory itself +const WRITE_DAC: u32 = 0x0004_0000; // rewrite this directory's DACL +const WRITE_OWNER: u32 = 0x0008_0000; // take ownership (implies WRITE_DAC) +const GENERIC_WRITE: u32 = 0x4000_0000; +const GENERIC_ALL: u32 = 0x1000_0000; + +/// Rights that make the **leaf** directory (the one holding `plm.exe`) unsafe: +/// creating a file there could side-load a DLL or drop a replacement binary, +/// and any delete/rename/DACL/owner right enables a swap. This is the strict +/// set. +const LEAF_DANGEROUS_MASK: u32 = FILE_ADD_FILE + | FILE_ADD_SUBDIRECTORY + | FILE_DELETE_CHILD + | DELETE + | WRITE_DAC + | WRITE_OWNER + | GENERIC_WRITE + | GENERIC_ALL; + +/// Rights that make an **ancestor** directory unsafe. An ancestor's harmless +/// "create a sibling" rights (`FILE_ADD_FILE` / `FILE_ADD_SUBDIRECTORY` / +/// `GENERIC_WRITE`) are deliberately NOT rejected — e.g. a drive root commonly +/// lets standard users create folders, which cannot compromise the protected +/// subtree. But rights that let an unprivileged principal delete/rename an +/// entry in the chain (`FILE_DELETE_CHILD`), delete/rename the ancestor itself +/// (`DELETE`), or rewrite its ownership/DACL (`WRITE_DAC` / `WRITE_OWNER` / +/// `GENERIC_ALL`) would let them displace or re-secure the subtree that holds +/// `plm.exe`, so those are rejected on every ancestor. +const ANCESTOR_DANGEROUS_MASK: u32 = + FILE_DELETE_CHILD | DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_ALL; + +/// A directory's role in the chain from `plm.exe` up to the volume root. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirRole { + /// The directory that directly contains `plm.exe`. + Leaf, + /// A directory above the leaf, up to and including the volume root. + Ancestor, +} + +impl DirRole { + fn dangerous_mask(self) -> u32 { + match self { + DirRole::Leaf => LEAF_DANGEROUS_MASK, + DirRole::Ancestor => ANCESTOR_DANGEROUS_MASK, + } + } +} + +/// Principals permitted to hold replacement rights on `plm.exe`'s directory. +/// Any *other* principal holding such rights means an unprivileged user could +/// swap the binary, so the gate fails closed. +const PRIVILEGED_SIDS: &[&str] = &[ + "S-1-5-18", // NT AUTHORITY\SYSTEM + "S-1-5-32-544", // BUILTIN\Administrators + // NT SERVICE\TrustedInstaller + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", +]; + +/// Well-known broad principals, retained for actionable diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BroadPrincipal { + Everyone, + AuthenticatedUsers, + BuiltinUsers, + Interactive, +} + +/// Labels a SID string as a well-known broad principal, if it is one. +pub(crate) fn broad_principal_from_sid(sid: &str) -> Option { + match sid.to_ascii_uppercase().as_str() { + "S-1-1-0" => Some(BroadPrincipal::Everyone), + "S-1-5-11" => Some(BroadPrincipal::AuthenticatedUsers), + "S-1-5-32-545" => Some(BroadPrincipal::BuiltinUsers), + "S-1-5-4" => Some(BroadPrincipal::Interactive), + _ => None, + } +} + +/// Whether `sid` is one of the privileged principals allowed to hold +/// replacement rights on the guarded directory. +pub(crate) fn is_privileged_sid(sid: &str) -> bool { + PRIVILEGED_SIDS + .iter() + .any(|privileged| sid.eq_ignore_ascii_case(privileged)) +} + +/// Whether an access `mask` intersects the `dangerous` set for a directory's +/// role. +pub(crate) fn mask_permits(mask: u32, dangerous: u32) -> bool { + mask & dangerous != 0 +} + +/// Interprets a raw ACE type byte. `Some(true)` = a standard allow ACE, +/// `Some(false)` = a standard deny ACE, `None` = any other type (object, +/// callback, conditional, audit, …). Callers **fail closed** on `None` rather +/// than skip it, since an unparsed ACE could grant access we cannot see. +pub(crate) fn ace_type_kind(ace_type: u8) -> Option { + match ace_type { + ACCESS_ALLOWED_ACE_TYPE => Some(true), + ACCESS_DENIED_ACE_TYPE => Some(false), + _ => None, + } +} + +/// Fail-closed presence check for a directory's DACL. A NULL DACL grants +/// everyone full control, so its absence is a rejection. +pub(crate) fn require_present_dacl(present: bool) -> Result<()> { + if present { + Ok(()) + } else { + bail!("the directory has a NULL DACL, which grants unrestricted access") + } +} + +/// One directory DACL entry reduced to what the classifier needs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DaclEntry { + pub sid: String, + pub mask: u32, + pub allow: bool, +} + +/// Fail-closed classification: returns the first `(sid, mask)` for an **allow** +/// ACE that grants any of `dangerous` to a **non-privileged** principal — i.e. +/// evidence that someone who is not SYSTEM/Administrators/TrustedInstaller could +/// compromise the directory. Deny ACEs are conservatively ignored (their +/// presence cannot make an unexpected allow safe). +pub(crate) fn replaceable_by(entries: &[DaclEntry], dangerous: u32) -> Option<(String, u32)> { + entries.iter().find_map(|entry| { + if !entry.allow || is_privileged_sid(&entry.sid) { + return None; + } + if mask_permits(entry.mask, dangerous) { + Some((entry.sid.clone(), entry.mask)) + } else { + None + } + }) +} + +/// Whether a signer certificate's Organization (`O`) name is Microsoft's. +/// Keyed on the organization name — stable across certificate rollover — rather +/// than a fixed thumbprint. +pub(crate) fn is_trusted_microsoft_org(org: &str) -> bool { + org.trim().eq_ignore_ascii_case("Microsoft Corporation") +} + +/// Keeps `plm.exe` open with a write/delete-denying share mode for its lifetime, +/// so the verified file cannot be swapped before/while `ShellExecuteExW` maps +/// it, and carries the **resolved** canonical launch path. Dropping the guard +/// closes the handle. +pub struct LaunchIntegrityGuard { + handle: HANDLE, + launch_path: PathBuf, +} + +// SAFETY: a Windows file HANDLE has no thread affinity; the guard uniquely owns +// it and only closes it on drop. +unsafe impl Send for LaunchIntegrityGuard {} + +impl LaunchIntegrityGuard { + /// The resolved, canonical **local DOS** path of the pinned binary. Callers + /// MUST launch this path (e.g. via `ShellExecuteExW`), never the original, + /// possibly aliased, path they passed to [`verify_and_pin_launch_binary`]. + /// It was resolved from the pinned handle, so SUBST / DOS-device / junction + /// / symlink aliases have already been collapsed to the underlying object. + pub fn launch_path(&self) -> &Path { + &self.launch_path + } +} + +impl Drop for LaunchIntegrityGuard { + fn drop(&mut self) { + if !self.handle.is_invalid() { + // SAFETY: `handle` was returned by `CreateFileW` and is owned here. + unsafe { + let _ = CloseHandle(self.handle); + } + } + } +} + +fn to_wide(path: &Path) -> Vec { + path.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +/// Verify `plm.exe` is Authenticode-trusted, Microsoft-signed, and located in a +/// directory chain unprivileged users cannot modify; return a guard that pins +/// the file (deny write/delete) and carries the **resolved** launch path. +/// +/// The critical ordering: the file is pinned **first** (following any alias to +/// the underlying object), then its exact object is resolved with +/// `GetFinalPathNameByHandleW`. Every subsequent path-based operation — signer +/// extraction, the ancestor-chain check, and ultimately `ShellExecuteExW` +/// (through [`LaunchIntegrityGuard::launch_path`]) — uses that resolved path, +/// never the caller's original string. This defeats SUBST / DOS-device +/// remapping / junction / symlink substitution between check and launch: +/// whatever alias the caller passed, we verify and launch the same stable +/// object we pinned. +/// +/// Fails closed with actionable errors. +pub fn verify_and_pin_launch_binary(path: &Path) -> Result { + // 1. Pin the object. `CreateFileW` follows any alias in `path` to the real + // underlying file; the deny-write/delete share mode then freezes it. + let handle = open_pinned_handle(path).with_context(|| { + format!( + "failed to open the guarded PLM binary {} with a write/delete-denying share mode \ + before verification", + path.display() + ) + })?; + // Own the handle immediately so any early return closes it. `launch_path` + // is filled in once resolved. + let mut guard = LaunchIntegrityGuard { + handle, + launch_path: PathBuf::new(), + }; + + // 2. Resolve the pinned object's canonical local DOS path. + let resolved = resolve_pinned_local_path(handle).with_context(|| { + format!( + "failed to resolve the stable local path of the guarded PLM binary {}", + path.display() + ) + })?; + guard.launch_path = resolved.clone(); + + // 3. Authenticode over the PINNED HANDLE (not a re-open by path), so trust + // is verified against the exact object we hold. + verify_authenticode(&resolved, handle).with_context(|| { + format!( + "Authenticode verification failed for {}", + resolved.display() + ) + })?; + + // 4. Signer identity from the resolved path (the object is pinned, so a + // re-open by that path cannot land on a different file). + let org = signer_organization(&resolved).with_context(|| { + format!( + "failed to read the signer identity of {} before elevating it", + resolved.display() + ) + })?; + if !is_trusted_microsoft_org(&org) { + bail!( + "refusing to elevate {}: it is signed by an untrusted publisher (organization {org:?}, \ + not Microsoft Corporation)", + resolved.display() + ); + } + + // 5. Ancestor chain of the RESOLVED path. The original alias chain need not + // stay trusted, since ShellExecuteExW launches the resolved stable path. + let dir = resolved.parent().with_context(|| { + format!( + "resolved guarded PLM path {} has no parent directory", + resolved.display() + ) + })?; + verify_directory_chain(dir)?; + + Ok(guard) +} + +/// Resolves the canonical local DOS path of the object behind a pinned handle +/// via `GetFinalPathNameByHandleW`, collapsing SUBST / DOS-device / junction / +/// symlink aliases. Rejects UNC/remote or non-DOS (device / GUID-volume) paths +/// that cannot be normalized to a stable local path. +fn resolve_pinned_local_path(handle: HANDLE) -> Result { + let mut buffer = vec![0u16; 512]; + // FILE_NAME_NORMALIZED | VOLUME_NAME_DOS (both are 0, but express intent): + // a normalized, drive-letter path for the pinned object. + let flags = GETFINALPATHNAMEBYHANDLE_FLAGS(FILE_NAME_NORMALIZED.0 | VOLUME_NAME_DOS.0); + let raw = loop { + // SAFETY: `handle` is a valid open file handle; `buffer` is writable. + let len = unsafe { GetFinalPathNameByHandleW(handle, &mut buffer, flags) } as usize; + if len == 0 { + return Err(WinError::from_thread()) + .context("GetFinalPathNameByHandleW failed for the pinned PLM binary"); + } + if len < buffer.len() { + break String::from_utf16_lossy(&buffer[..len]); + } + // Too small: `len` is the required size including the NUL. Grow + retry. + buffer = vec![0u16; len + 1]; + }; + normalize_local_dos_path(&raw) +} + +/// Normalizes a `GetFinalPathNameByHandleW(VOLUME_NAME_DOS)` result (a +/// `\\?\`-prefixed path) into a plain local DOS `PathBuf`, or fails closed for +/// UNC/remote and non-drive-letter (device / GUID-volume) paths. +pub(crate) fn normalize_local_dos_path(raw: &str) -> Result { + let stripped = raw.strip_prefix(r"\\?\").unwrap_or(raw); + if stripped.len() >= 4 && stripped[..4].eq_ignore_ascii_case("UNC\\") { + bail!( + "the guarded PLM binary resolved to a UNC/remote path ({raw}); refusing to elevate a \ + non-local binary" + ); + } + let bytes = stripped.as_bytes(); + let is_local_dos = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/'); + if !is_local_dos { + bail!( + "the guarded PLM binary resolved to a non-DOS/device path ({raw}); refusing because it \ + cannot be normalized to a stable local drive-letter path" + ); + } + Ok(PathBuf::from(stripped)) +} + +/// Hardens the process's DLL search order to System32 only, so subsequent +/// `LoadLibrary` calls for a bare DLL name cannot resolve to an adjacent +/// (potentially attacker-planted) DLL. `plm.exe` is a self-contained Rust/MSVC +/// binary that links only system DLLs (kernel32, advapi32, ntdll, the UCRT/ +/// vcruntime, crypt32/wintrust) — all resolved from `System32` — and ships no +/// private adjacent DLLs, so this never removes a search path it needs. It is +/// defense-in-depth atop the directory/ancestor integrity checks, which already +/// guarantee an unprivileged user cannot drop a DLL beside `plm.exe`. +/// +/// Called at the start of the elevated child so it applies before any runtime +/// `LoadLibrary`. (Static imports are resolved by the loader before `main`, but +/// the verified, non-user-writable install directory already protects those.) +pub fn harden_dll_search_path() -> Result<()> { + // SAFETY: a process-global search-policy tweak with no unsafe preconditions. + unsafe { SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32) } + .context("failed to restrict the elevated PLM DLL search path to System32") +} + +fn open_pinned_handle(path: &Path) -> Result { + let wide = to_wide(path); + // GENERIC_READ + share READ only: others may still read/execute the image, + // but no one can open it for write, and it cannot be renamed or deleted + // while this handle is held — the swap window is closed. `CreateFileW` + // follows any SUBST/junction/symlink alias to the underlying object. + let handle = unsafe { + CreateFileW( + PCWSTR(wide.as_ptr()), + FILE_GENERIC_READ.0, + FILE_SHARE_READ, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + ) + }?; + Ok(handle) +} + +fn verify_authenticode(path: &Path, pinned: HANDLE) -> Result<()> { + let wide = to_wide(path); + let mut file_info = WINTRUST_FILE_INFO { + cbStruct: std::mem::size_of::() as u32, + pcwszFilePath: PCWSTR(wide.as_ptr()), + // Verify the exact pinned object rather than re-reading by path. + hFile: pinned, + pgKnownSubject: ptr::null_mut(), + }; + let mut data = WINTRUST_DATA { + cbStruct: std::mem::size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + // Revocation checking is enabled across the whole chain, excluding the + // (self-signed) root — see `REVOCATION_CHECKS` / `REVOCATION_PROVIDER_FLAGS`. + fdwRevocationChecks: REVOCATION_CHECKS, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: WINTRUST_DATA_0 { + pFile: &mut file_info, + }, + dwStateAction: WTD_STATEACTION_VERIFY, + dwProvFlags: REVOCATION_PROVIDER_FLAGS, + ..Default::default() + }; + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + // SAFETY: `action` and `data` are valid and outlive the call. A null hwnd + // with WTD_UI_NONE performs a non-interactive verification. + let status = unsafe { + WinVerifyTrust( + HWND::default(), + &mut action, + &mut data as *mut _ as *mut c_void, + ) + }; + + // Always release the per-call trust state, regardless of the result. + data.dwStateAction = WTD_STATEACTION_CLOSE; + unsafe { + let _ = WinVerifyTrust( + HWND::default(), + &mut action, + &mut data as *mut _ as *mut c_void, + ); + } + + if status != 0 { + bail!( + "the binary is not Authenticode-trusted (WinVerifyTrust status {:#010x}); it is \ + unsigned, tampered, chains to an untrusted root, or its certificate is revoked. \ + Whole-chain revocation checking is enabled and fails closed: if revocation status \ + cannot be determined (offline, no cached CRL/OCSP, or an unreachable responder), \ + launch is refused. Signed end-to-end runs therefore require revocation availability \ + or a valid cached revocation status.", + status as u32 + ); + } + Ok(()) +} + +/// RAII closers for the crypto handles returned by `CryptQueryObject`. +struct MsgGuard(*const c_void); +impl Drop for MsgGuard { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + let _ = CryptMsgClose(Some(self.0)); + } + } + } +} +struct StoreGuard(HCERTSTORE); +impl Drop for StoreGuard { + fn drop(&mut self) { + if !self.0 .0.is_null() { + unsafe { + let _ = CertCloseStore(Some(self.0), 0); + } + } + } +} +struct CertGuard(*const CERT_CONTEXT); +impl Drop for CertGuard { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + let _ = CertFreeCertificateContext(Some(self.0)); + } + } + } +} + +fn signer_organization(path: &Path) -> Result { + let wide = to_wide(path); + let mut store = HCERTSTORE::default(); + let mut msg: *mut c_void = ptr::null_mut(); + // SAFETY: `wide` is a valid NUL-terminated path; out-params are valid. + unsafe { + CryptQueryObject( + CERT_QUERY_OBJECT_FILE, + wide.as_ptr() as *const c_void, + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, + 0, + None, + None, + None, + Some(&mut store), + Some(&mut msg), + None, + ) + } + .context("CryptQueryObject failed (the binary has no embedded PKCS#7 signature)")?; + let _store_guard = StoreGuard(store); + let _msg_guard = MsgGuard(msg); + + // Fetch the first signer info (Issuer + SerialNumber identify its cert). + let mut signer_len = 0u32; + unsafe { CryptMsgGetParam(msg, CMSG_SIGNER_INFO_PARAM, 0, None, &mut signer_len) } + .context("CryptMsgGetParam(size) failed")?; + let mut signer_buf = vec![0u8; signer_len as usize]; + unsafe { + CryptMsgGetParam( + msg, + CMSG_SIGNER_INFO_PARAM, + 0, + Some(signer_buf.as_mut_ptr() as *mut c_void), + &mut signer_len, + ) + } + .context("CryptMsgGetParam failed")?; + // SAFETY: the buffer holds a CMSG_SIGNER_INFO as populated above. The Vec is + // only 1-byte aligned, so read the struct with `read_unaligned` rather than + // forming a misaligned reference (which would be UB). + let signer = unsafe { ptr::read_unaligned(signer_buf.as_ptr() as *const CMSG_SIGNER_INFO) }; + + // Deep-copy the Issuer and SerialNumber blobs into owned buffers, so the + // CERT_INFO used for the certificate lookup does not rely on pointers into + // `signer_buf` (nor into the just-read `signer` copy) remaining valid. + let issuer_bytes = copy_blob(signer.Issuer.pbData, signer.Issuer.cbData); + let serial_bytes = copy_blob(signer.SerialNumber.pbData, signer.SerialNumber.cbData); + let cert_info = CERT_INFO { + Issuer: CRYPT_INTEGER_BLOB { + cbData: issuer_bytes.len() as u32, + pbData: issuer_bytes.as_ptr() as *mut u8, + }, + SerialNumber: CRYPT_INTEGER_BLOB { + cbData: serial_bytes.len() as u32, + pbData: serial_bytes.as_ptr() as *mut u8, + }, + ..Default::default() + }; + // SAFETY: `store` is valid; `cert_info` (and the owned blob buffers it + // points at) outlive the call. + let cert = unsafe { + CertFindCertificateInStore( + store, + CERT_QUERY_ENCODING_TYPE(X509_ASN_ENCODING.0 | PKCS_7_ASN_ENCODING.0), + 0, + CERT_FIND_SUBJECT_CERT, + Some(&cert_info as *const _ as *const c_void), + None, + ) + }; + // Keep the owned blob buffers alive until after the lookup. + drop(issuer_bytes); + drop(serial_bytes); + if cert.is_null() { + bail!("could not locate the signer certificate in the embedded PKCS#7 store"); + } + let _cert_guard = CertGuard(cert); + + cert_organization_name(cert) +} + +/// Copies a `cbData`/`pbData` crypto blob into an owned `Vec`. An empty or +/// null blob yields an empty vector. +fn copy_blob(pb_data: *const u8, cb_data: u32) -> Vec { + if pb_data.is_null() || cb_data == 0 { + return Vec::new(); + } + // SAFETY: `pb_data` points to `cb_data` valid bytes in the signer buffer. + unsafe { std::slice::from_raw_parts(pb_data, cb_data as usize) }.to_vec() +} + +fn cert_organization_name(cert: *const CERT_CONTEXT) -> Result { + // szOID_ORGANIZATION_NAME. Passed as the type parameter for + // CERT_NAME_ATTR_TYPE. + let oid = PCSTR(c"2.5.4.10".as_ptr() as *const u8); + let type_para = oid.0 as *const c_void; + + // First call: required length (in wide chars, including the NUL). + let len = unsafe { CertGetNameStringW(cert, CERT_NAME_ATTR_TYPE, 0, Some(type_para), None) }; + if len <= 1 { + bail!("the signer certificate has no Organization (O) name"); + } + let mut buf = vec![0u16; len as usize]; + let written = unsafe { + CertGetNameStringW( + cert, + CERT_NAME_ATTR_TYPE, + 0, + Some(type_para), + Some(&mut buf), + ) + }; + if written == 0 { + bail!("failed to read the signer certificate Organization name"); + } + // `written` includes the terminating NUL. + let end = (written as usize).saturating_sub(1).min(buf.len()); + Ok(String::from_utf16_lossy(&buf[..end])) +} + +/// The DACL and owner of a directory, reduced to what the gate needs. +struct DirectorySecurity { + owner: String, + dacl: Vec, +} + +/// Verify the whole ancestry chain from the directory that holds `plm.exe` +/// (the leaf) up through the volume root. The leaf must reject any side-load / +/// create / replace right; each ancestor must reject rights that would let a +/// non-privileged principal delete, rename, or re-secure the protected subtree +/// (but not harmless create-a-sibling rights). Every directory in the chain +/// must additionally be **owned** by a privileged principal, because an owner +/// has implicit `WRITE_DAC` and could grant itself anything. +fn verify_directory_chain(leaf: &Path) -> Result<()> { + verify_one_directory(leaf, DirRole::Leaf)?; + let mut current = leaf.to_path_buf(); + while let Some(parent) = current.parent().map(Path::to_path_buf) { + if parent == current { + break; + } + verify_one_directory(&parent, DirRole::Ancestor)?; + current = parent; + } + Ok(()) +} + +fn verify_one_directory(dir: &Path, role: DirRole) -> Result<()> { + let security = read_directory_security(dir) + .with_context(|| format!("failed to read the security of {}", dir.display()))?; + + if !is_privileged_sid(&security.owner) { + bail!( + "refusing to elevate: {} ({role:?}) is owned by non-privileged principal {} — an \ + owner has implicit WRITE_DAC and can grant itself replacement rights. It must be \ + owned by SYSTEM, Administrators, or TrustedInstaller.", + dir.display(), + security.owner + ); + } + + if let Some((sid, mask)) = replaceable_by(&security.dacl, role.dangerous_mask()) { + let label = broad_principal_from_sid(&sid) + .map(|principal| format!("{principal:?} ({sid})")) + .unwrap_or_else(|| sid.clone()); + bail!( + "refusing to elevate: {} ({role:?}) grants rights (access mask {mask:#010x}) to \ + non-privileged principal {label} that could replace or displace plm.exe. Install it \ + under a subtree writable only by SYSTEM, Administrators, or TrustedInstaller.", + dir.display() + ); + } + Ok(()) +} + +/// Reads the owner SID and effective DACL of `dir`. Includes inherited ACEs +/// (they apply to this object); skips inherit-only ACEs (they do not). Fails +/// closed on a NULL DACL, a missing owner, or any ACE type that is not a +/// standard allow/deny. +fn read_directory_security(dir: &Path) -> Result { + let wide = to_wide(dir); + let mut owner_psid = PSID::default(); + let mut dacl: *mut ACL = ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `wide` is a valid NUL-terminated path; out-params are valid. + let rc = unsafe { + GetNamedSecurityInfoW( + PCWSTR(wide.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&mut owner_psid), + None, + Some(&mut dacl), + None, + &mut sd, + ) + }; + if rc != ERROR_SUCCESS { + bail!( + "GetNamedSecurityInfoW failed for {} (error {})", + dir.display(), + rc.0 + ); + } + let _sd_guard = SecurityDescriptorGuard(sd); + + if owner_psid.0.is_null() { + bail!("the directory {} has no owner", dir.display()); + } + let owner = sid_to_string(owner_psid)?; + + require_present_dacl(!dacl.is_null()) + .with_context(|| format!("the guarded PLM directory {}", dir.display()))?; + + let dacl = parse_dacl(dacl) + .with_context(|| format!("failed to parse the DACL of {}", dir.display()))?; + Ok(DirectorySecurity { owner, dacl }) +} + +/// Walks a non-NULL DACL into `(sid, mask, allow)` entries, failing closed on +/// any ACE we do not fully understand. +fn parse_dacl(dacl: *const ACL) -> Result> { + if dacl.is_null() { + bail!("cannot parse a NULL DACL"); + } + let mut info = ACL_SIZE_INFORMATION::default(); + // SAFETY: `dacl` is a valid non-NULL ACL pointer. + unsafe { + GetAclInformation( + dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } + .context("GetAclInformation failed")?; + + let acl_len = info.AclBytesInUse as usize; + if acl_len < std::mem::size_of::() { + bail!("the DACL is shorter than its fixed header"); + } + let acl_bytes = read_current_process_memory(dacl.cast(), acl_len) + .context("failed to copy the DACL into bounded local storage")?; + + let mut entries = Vec::with_capacity(info.AceCount as usize); + let mut ace_offset = std::mem::size_of::(); + for index in 0..info.AceCount { + let header_end = ace_offset + .checked_add(std::mem::size_of::()) + .context("the ACE header address range overflowed")?; + let header = acl_bytes + .get(ace_offset..header_end) + .with_context(|| format!("ACE {index} has a header outside the DACL bounds"))?; + let ace_type = header[0]; + let ace_flags = header[1]; + let ace_len = u16::from_le_bytes([header[2], header[3]]) as usize; + let ace_end = ace_offset + .checked_add(ace_len) + .context("the ACE address range overflowed")?; + if ace_len < std::mem::size_of::() || ace_end > acl_bytes.len() { + bail!("ACE {index} has an invalid size ({ace_len} bytes)"); + } + let ace_bytes = acl_bytes + .get(ace_offset..ace_end) + .with_context(|| format!("ACE {index} extends outside the DACL bounds"))?; + ace_offset = ace_end; + + if header_end > acl_bytes.len() { + bail!("ACE {index} has a header outside the DACL bounds"); + } + if ace_flags & INHERIT_ONLY_ACE != 0 { + // Inherit-only ACEs do not apply to this directory. + continue; + } + let allow = match ace_type_kind(ace_type) { + Some(kind) => kind, + None => bail!( + "the DACL contains an unsupported ACE type {:#04x} (object/callback/conditional); \ + failing closed because its effect cannot be classified", + ace_type + ), + }; + // ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE share layout through SidStart. + let mask_offset = std::mem::offset_of!(ACCESS_ALLOWED_ACE, Mask); + let sid_offset = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + const SID_FIXED_HEADER_LEN: usize = 8; + let minimum_len = sid_offset + .checked_add(SID_FIXED_HEADER_LEN) + .context("the minimum ACE size overflowed")?; + if ace_len < minimum_len { + bail!("ACE {index} is too short to contain a SID"); + } + let mask_bytes: [u8; std::mem::size_of::()] = ace_bytes + .get(mask_offset..mask_offset + std::mem::size_of::()) + .context("the ACE mask extends beyond the ACE bounds")? + .try_into() + .context("the ACE mask has an invalid length")?; + let mask = u32::from_le_bytes(mask_bytes); + let sid_header = ace_bytes + .get(sid_offset..sid_offset + SID_FIXED_HEADER_LEN) + .context("the SID header extends beyond the ACE bounds")?; + let subauthority_count = sid_header[1] as usize; + let sid_len = SID_FIXED_HEADER_LEN + .checked_add( + subauthority_count + .checked_mul(std::mem::size_of::()) + .context("the SID subauthority length overflowed")?, + ) + .context("the SID length overflowed")?; + if sid_offset + .checked_add(sid_len) + .is_none_or(|required_len| required_len > ace_len) + { + bail!("ACE {index} contains a SID that extends beyond the ACE bounds"); + } + let sid_bytes = ace_bytes + .get(sid_offset..sid_offset + sid_len) + .context("the SID extends beyond the ACE bounds")?; + let sid_string = sid_bytes_to_string(sid_bytes)?; + entries.push(DaclEntry { + sid: sid_string, + mask, + allow, + }); + } + Ok(entries) +} + +fn read_current_process_memory(address: *const c_void, len: usize) -> Result> { + let mut bytes = vec![0u8; len]; + let mut bytes_read = 0usize; + // SAFETY: `ReadProcessMemory` validates the source range in the current + // process and writes into the fully allocated destination buffer. + unsafe { + ReadProcessMemory( + GetCurrentProcess(), + address, + bytes.as_mut_ptr().cast(), + len, + Some(&mut bytes_read), + ) + } + .context("ReadProcessMemory failed")?; + if bytes_read != len { + bail!("ReadProcessMemory returned {bytes_read} of {len} requested bytes"); + } + Ok(bytes) +} + +fn sid_bytes_to_string(bytes: &[u8]) -> Result { + if bytes.len() < 8 { + bail!("the SID is shorter than its fixed header"); + } + let required_len = 8usize + .checked_add( + (bytes[1] as usize) + .checked_mul(std::mem::size_of::()) + .context("the SID subauthority length overflowed")?, + ) + .context("the SID length overflowed")?; + if required_len > bytes.len() { + bail!("the SID extends beyond its bounded buffer"); + } + sid_to_string(PSID(bytes.as_ptr() as *mut c_void)) +} + +struct SecurityDescriptorGuard(PSECURITY_DESCRIPTOR); +impl Drop for SecurityDescriptorGuard { + fn drop(&mut self) { + if !self.0 .0.is_null() { + unsafe { + let _ = LocalFree(Some(HLOCAL(self.0 .0))); + } + } + } +} + +fn sid_to_string(sid: PSID) -> Result { + let mut string_sid = PWSTR::null(); + unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } + .context("ConvertSidToStringSidW failed")?; + // SAFETY: `string_sid` is a valid NUL-terminated wide string allocated by + // the call; freed below. + let value = unsafe { string_sid.to_string() }.unwrap_or_default(); + unsafe { + let _ = LocalFree(Some(HLOCAL(string_sid.0 as *mut c_void))); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn microsoft_organization_is_trusted_case_insensitively() { + assert!(is_trusted_microsoft_org("Microsoft Corporation")); + assert!(is_trusted_microsoft_org(" microsoft corporation ")); + assert!(!is_trusted_microsoft_org("Microsoft")); + assert!(!is_trusted_microsoft_org("Contoso Corporation")); + assert!(!is_trusted_microsoft_org("")); + } + + #[test] + fn broad_principals_are_labelled() { + assert_eq!( + broad_principal_from_sid("S-1-1-0"), + Some(BroadPrincipal::Everyone) + ); + assert_eq!( + broad_principal_from_sid("s-1-5-11"), + Some(BroadPrincipal::AuthenticatedUsers) + ); + assert_eq!( + broad_principal_from_sid("S-1-5-32-545"), + Some(BroadPrincipal::BuiltinUsers) + ); + assert_eq!(broad_principal_from_sid("S-1-5-21-1-2-3-1001"), None); + } + + #[test] + fn privileged_sids_are_recognized() { + assert!(is_privileged_sid("S-1-5-18")); + assert!(is_privileged_sid("s-1-5-32-544")); + assert!(is_privileged_sid( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + )); + assert!(!is_privileged_sid("S-1-5-32-545")); // BUILTIN\Users + assert!(!is_privileged_sid("S-1-1-0")); // Everyone + assert!(!is_privileged_sid("S-1-5-21-1-2-3-1001")); // a normal user + } + + #[test] + fn ace_types_fail_closed_on_anything_but_standard_allow_deny() { + assert_eq!(ace_type_kind(ACCESS_ALLOWED_ACE_TYPE), Some(true)); + assert_eq!(ace_type_kind(ACCESS_DENIED_ACE_TYPE), Some(false)); + // Object, callback, conditional (0x0c-0x0f), and audit ACE types are + // unsupported and must classify as `None` (the caller then fails + // closed). + for unsupported in [0x02u8, 0x05, 0x06, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f] { + assert_eq!(ace_type_kind(unsupported), None, "type {unsupported:#04x}"); + } + } + + #[test] + fn null_dacl_fails_closed() { + assert!(require_present_dacl(true).is_ok()); + let error = require_present_dacl(false).expect_err("a NULL DACL must be rejected"); + assert!(error.to_string().contains("NULL DACL"), "got: {error}"); + } + + #[test] + fn leaf_and_ancestor_masks_differ_for_create_rights() { + let leaf = DirRole::Leaf.dangerous_mask(); + let ancestor = DirRole::Ancestor.dangerous_mask(); + // Creating a file/subdir is dangerous in the leaf (side-load / drop a + // replacement) but harmless "create a sibling" for an ancestor. + assert!(mask_permits(FILE_ADD_FILE, leaf)); + assert!(mask_permits(FILE_ADD_SUBDIRECTORY, leaf)); + assert!(mask_permits(GENERIC_WRITE, leaf)); + assert!(!mask_permits(FILE_ADD_FILE, ancestor)); + assert!(!mask_permits(FILE_ADD_SUBDIRECTORY, ancestor)); + assert!(!mask_permits(GENERIC_WRITE, ancestor)); + // Delete/rename/replace and DACL/owner rewrites are dangerous for both. + for right in [ + FILE_DELETE_CHILD, + DELETE, + WRITE_DAC, + WRITE_OWNER, + GENERIC_ALL, + ] { + assert!(mask_permits(right, leaf), "leaf {right:#010x}"); + assert!(mask_permits(right, ancestor), "ancestor {right:#010x}"); + } + // Read/execute-only is safe for both. + assert!(!mask_permits(0x0020 /* FILE_EXECUTE */, leaf)); + assert!(!mask_permits(0x0001 /* FILE_READ_DATA */, ancestor)); + } + + fn entry(sid: &str, mask: u32, allow: bool) -> DaclEntry { + DaclEntry { + sid: sid.to_string(), + mask, + allow, + } + } + + #[test] + fn directory_with_only_privileged_writers_is_accepted() { + let entries = vec![ + entry("S-1-5-18", GENERIC_ALL, true), // SYSTEM + entry("S-1-5-32-544", GENERIC_ALL, true), // Administrators + entry("S-1-5-32-545", 0x0020 | 0x0001, true), // Users: read/execute + entry("S-1-5-11", 0x0020 | 0x0001, true), // Authenticated Users: R/X + ]; + assert!(replaceable_by(&entries, LEAF_DANGEROUS_MASK).is_none()); + assert!(replaceable_by(&entries, ANCESTOR_DANGEROUS_MASK).is_none()); + } + + #[test] + fn directory_writable_by_broad_principal_is_rejected() { + let entries = vec![ + entry("S-1-5-18", GENERIC_ALL, true), + entry("S-1-1-0", FILE_ADD_FILE, true), // Everyone can create files + ]; + let (sid, mask) = replaceable_by(&entries, LEAF_DANGEROUS_MASK).expect("must reject"); + assert_eq!(sid, "S-1-1-0"); + assert_eq!(mask, FILE_ADD_FILE); + } + + #[test] + fn create_sibling_at_ancestor_is_allowed_but_delete_child_is_not() { + // An ancestor (e.g. a drive root) that lets Users create folders is + // fine; one that lets them delete children is not. + let create_only = vec![entry("S-1-5-32-545", FILE_ADD_SUBDIRECTORY, true)]; + assert!(replaceable_by(&create_only, ANCESTOR_DANGEROUS_MASK).is_none()); + // The same right on the LEAF is rejected. + assert!(replaceable_by(&create_only, LEAF_DANGEROUS_MASK).is_some()); + + let delete_child = vec![entry("S-1-5-32-545", FILE_DELETE_CHILD, true)]; + assert!(replaceable_by(&delete_child, ANCESTOR_DANGEROUS_MASK).is_some()); + } + + #[test] + fn directory_writable_by_a_normal_user_is_rejected() { + let entries = vec![ + entry("S-1-5-18", GENERIC_ALL, true), + entry("S-1-5-21-1-2-3-1001", GENERIC_WRITE, true), // a specific user + ]; + assert!(replaceable_by(&entries, LEAF_DANGEROUS_MASK).is_some()); + } + + #[test] + fn deny_aces_do_not_trigger_rejection() { + // A deny ACE, even to a broad principal with dangerous rights, is not + // itself evidence of write access. + let entries = vec![entry("S-1-1-0", GENERIC_ALL, false)]; + assert!(replaceable_by(&entries, LEAF_DANGEROUS_MASK).is_none()); + } + + #[test] + fn a_user_writable_temp_directory_is_rejected() { + // Deterministic, requires no signed binary: a freshly created temp + // directory under the user profile is either owned by the current + // (unprivileged) user or grants that user replacement rights, so the + // chain gate must reject it. + let dir = tempfile::tempdir().expect("temp dir"); + let error = verify_one_directory(dir.path(), DirRole::Leaf) + .expect_err("a user-writable temp directory must be rejected"); + let message = error.to_string(); + assert!( + message.contains("non-privileged") || message.contains("owned by"), + "unexpected error: {message}" + ); + } + + #[test] + fn a_protected_leaf_under_a_user_controlled_ancestor_is_rejected() { + // The leaf itself is a temp dir (user-controlled), so the chain walk + // must reject it — exercising ancestor/owner enforcement on a real + // path. (A leaf under System32 would pass; we cannot create such a + // fixture without privileges, so we assert the rejection direction.) + let dir = tempfile::tempdir().expect("temp dir"); + let child = dir.path().join("MXC"); + std::fs::create_dir(&child).expect("create child dir"); + assert!(verify_directory_chain(&child).is_err()); + } + + #[test] + fn read_directory_security_reads_a_real_directory() { + // System32 must be readable and owned by a privileged principal — a + // real end-to-end exercise of owner + DACL retrieval and the ACE + // fail-closed parser against a production directory. + let system32 = std::path::Path::new(r"C:\Windows\System32"); + if !system32.is_dir() { + eprintln!("skipping: {} not present", system32.display()); + return; + } + let security = + read_directory_security(system32).expect("System32 security must be readable"); + assert!( + is_privileged_sid(&security.owner), + "System32 owner should be privileged, got {}", + security.owner + ); + assert!(!security.dacl.is_empty()); + } + + #[test] + fn pin_file_denies_write_and_delete_while_held() { + // Deterministic, no signing required: while the integrity guard lives, + // the file cannot be opened for write, deleted, or renamed; after the + // guard drops, those succeed. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("pinned.bin"); + std::fs::write(&path, b"payload").expect("write file"); + + let handle = open_pinned_handle(&path).expect("pin the file"); + let guard = LaunchIntegrityGuard { + handle, + launch_path: path.clone(), + }; + assert!( + std::fs::OpenOptions::new().write(true).open(&path).is_err(), + "opening the pinned file for write must fail" + ); + assert!( + std::fs::remove_file(&path).is_err(), + "deleting the pinned file must fail" + ); + assert!( + std::fs::rename(&path, dir.path().join("renamed.bin")).is_err(), + "renaming the pinned file must fail" + ); + + drop(guard); + // After the guard is released, the file can be replaced/removed. + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("write must succeed after the guard drops"); + std::fs::remove_file(&path).expect("delete must succeed after the guard drops"); + } + + #[test] + fn pin_file_rejects_a_missing_path() { + let dir = tempfile::tempdir().expect("temp dir"); + let missing = dir.path().join("does-not-exist.exe"); + assert!( + open_pinned_handle(&missing).is_err(), + "a missing file must be rejected" + ); + assert!( + verify_and_pin_launch_binary(&missing).is_err(), + "verify_and_pin must reject a missing binary" + ); + } + + #[test] + fn normalize_local_dos_path_accepts_drive_letters_and_rejects_non_local() { + assert_eq!( + normalize_local_dos_path(r"\\?\C:\Program Files\MXC\plm.exe").unwrap(), + PathBuf::from(r"C:\Program Files\MXC\plm.exe") + ); + // Lowercase drive and a path with no verbatim prefix both normalize. + assert_eq!( + normalize_local_dos_path(r"\\?\d:\x\plm.exe").unwrap(), + PathBuf::from(r"d:\x\plm.exe") + ); + assert_eq!( + normalize_local_dos_path(r"C:\already\plain.exe").unwrap(), + PathBuf::from(r"C:\already\plain.exe") + ); + // UNC/remote and device / GUID-volume forms fail closed. + assert!(normalize_local_dos_path(r"\\?\UNC\server\share\plm.exe").is_err()); + assert!(normalize_local_dos_path(r"\\?\unc\server\share\plm.exe").is_err()); + assert!( + normalize_local_dos_path(r"\\?\Volume{12345678-0000-0000-0000-000000000000}\x") + .is_err() + ); + assert!(normalize_local_dos_path(r"\\server\share\plm.exe").is_err()); + assert!(normalize_local_dos_path(r"\Device\HarddiskVolume3\x").is_err()); + } + + #[test] + fn resolve_pinned_local_path_returns_the_stable_local_object() { + // Deterministic: resolving a pinned temp file yields a local DOS path + // that names the same file (its final component matches). + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("resolve-me.bin"); + std::fs::write(&path, b"x").expect("write file"); + + let handle = open_pinned_handle(&path).expect("pin"); + let guard = LaunchIntegrityGuard { + handle, + launch_path: PathBuf::new(), + }; + let resolved = resolve_pinned_local_path(guard.handle).expect("resolve"); + assert_eq!( + resolved.file_name().and_then(|n| n.to_str()), + Some("resolve-me.bin"), + "resolved: {}", + resolved.display() + ); + // It is a local drive-letter path and refers to the same file. + let s = resolved.to_string_lossy(); + assert!( + s.as_bytes().get(1) == Some(&b':'), + "expected a drive-letter path, got {s}" + ); + assert!(resolved.is_file()); + } + + #[test] + fn resolve_defeats_a_symlink_alias_if_symlinks_can_be_created() { + // If the environment permits symlink creation (admin or Developer + // Mode), opening through a symlink and resolving must yield the + // underlying target, never the alias path. Skips otherwise. + let dir = tempfile::tempdir().expect("temp dir"); + let target = dir.path().join("target.bin"); + std::fs::write(&target, b"payload").expect("write target"); + let link = dir.path().join("alias.bin"); + if std::os::windows::fs::symlink_file(&target, &link).is_err() { + eprintln!("skipping: symlink creation not permitted on this host"); + return; + } + + let handle = open_pinned_handle(&link).expect("pin via symlink"); + let guard = LaunchIntegrityGuard { + handle, + launch_path: PathBuf::new(), + }; + let resolved = resolve_pinned_local_path(guard.handle).expect("resolve"); + assert_eq!( + resolved.file_name().and_then(|n| n.to_str()), + Some("target.bin"), + "the resolved launch path must be the target, not the alias: {}", + resolved.display() + ); + assert!( + !resolved + .to_string_lossy() + .to_ascii_lowercase() + .contains("alias.bin"), + "resolution must never return the original alias: {}", + resolved.display() + ); + } + + #[test] + fn unsigned_binary_fails_authenticode_and_signer_read() { + // An unsigned file deterministically fails both the Authenticode check + // and the signer-identity read — no signed fixture required. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("unsigned.exe"); + std::fs::write(&path, b"MZ not a real signed PE").expect("write file"); + + let handle = open_pinned_handle(&path).expect("pin the unsigned file"); + let guard = LaunchIntegrityGuard { + handle, + launch_path: path.clone(), + }; + assert!( + verify_authenticode(&path, guard.handle).is_err(), + "an unsigned file must fail Authenticode verification" + ); + assert!( + signer_organization(&path).is_err(), + "an unsigned file has no signer organization" + ); + } + + #[test] + fn a_microsoft_signed_system_binary_is_recognized_if_available() { + // Best-effort positive check: if an embedded-signed Microsoft binary is + // present, its Authenticode chain must verify and its signer + // organization must be Microsoft. Many system binaries are catalog- + // signed (no embedded signature), so this test skips when no suitable + // fixture verifies — it never fails on such environments. + let candidates = [ + r"C:\Windows\System32\wpr.exe", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + r"C:\Windows\System32\dpnsvr.exe", + ]; + for candidate in candidates { + let path = std::path::Path::new(candidate); + if !path.is_file() { + continue; + } + let Ok(handle) = open_pinned_handle(path) else { + continue; + }; + let guard = LaunchIntegrityGuard { + handle, + launch_path: path.to_path_buf(), + }; + if verify_authenticode(path, guard.handle).is_err() { + // Likely catalog-signed on this build; try the next candidate. + continue; + } + match signer_organization(path) { + Ok(org) => { + assert!( + is_trusted_microsoft_org(&org), + "{candidate} is Microsoft-signed but org was {org:?}" + ); + return; + } + Err(_) => continue, + } + } + eprintln!("skipping: no embedded-signed Microsoft fixture available on this host"); + } + + #[test] + fn revocation_policy_is_whole_chain_excluding_root() { + // Pure policy assertion (the runtime WinVerifyTrust result is + // environment-dependent): revocation is checked across the whole chain, + // excluding the self-signed root. + assert_eq!(REVOCATION_CHECKS, WTD_REVOKE_WHOLECHAIN); + assert_eq!( + REVOCATION_PROVIDER_FLAGS, + WTD_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT + ); + } +}