From 4e2a78887764bb99ff812d6a93e492de396a4f6c Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 6 Aug 2026 10:22:43 -0700 Subject: [PATCH 01/50] Refactor PLM to use canonical analyzer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/Cargo.lock | 3 + src/host/plm/Cargo.toml | 11 +- src/host/plm/readme.md | 22 +- src/host/plm/src/access_failure.rs | 613 ------------ src/host/plm/src/analysis.rs | 197 ++++ src/host/plm/src/config.rs | 4 +- src/host/plm/src/event_parser.rs | 1489 ---------------------------- src/host/plm/src/extract_caps.rs | 8 +- src/host/plm/src/lib.rs | 4 +- src/host/plm/src/log.rs | 25 +- src/host/plm/src/main.rs | 16 +- src/host/plm/src/start.rs | 6 + src/host/plm/src/stop.rs | 332 +++++-- 13 files changed, 510 insertions(+), 2220 deletions(-) delete mode 100644 src/host/plm/src/access_failure.rs create mode 100644 src/host/plm/src/analysis.rs delete mode 100644 src/host/plm/src/event_parser.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index bd80afa2c..b63188690 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1737,8 +1737,11 @@ dependencies = [ "chrono", "clap", "embed-manifest", + "learning_mode_core", + "learning_mode_windows", "mxc_build_common", "quick-xml", + "serde", "serde_json", "tempfile", "windows", diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index 1c3f7b521..ef1e8b405 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -22,12 +22,12 @@ test = false [dependencies] clap.workspace = true anyhow.workspace = true -# Portable deps (config / access_event / event_parser) must compile on -# every target so their unit tests run in cross-platform CI. The -# `windows` crate stays target-gated below. +# Portable config-generation dependencies must compile on every target so +# their unit tests run in cross-platform CI. Windows capture and analysis +# dependencies stay target-gated below. serde_json.workspace = true +serde.workspace = true chrono.workspace = true -quick-xml.workspace = true tempfile.workspace = true [target.'cfg(target_os = "windows")'.dependencies] @@ -37,6 +37,8 @@ windows = { workspace = true, features = [ "Win32_System_Threading", ] } wxc_common = { workspace = true } +learning_mode_core = { workspace = true } +learning_mode_windows = { workspace = true } [build-dependencies] mxc_build_common.workspace = true @@ -46,3 +48,4 @@ embed-manifest = "1.4" [dev-dependencies] tempfile.workspace = true +quick-xml.workspace = true diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index 1a2dc33d9..db3c00bf1 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -1,8 +1,8 @@ # PLM — Permissive Learning Mode -`plm.exe` is the Windows-only trace driver for permissive learning mode. Long-form, it captures the access-denied events emitted by Windows' permissive sandbox layer, decodes them into structured findings, and merges those findings back into an MXC container config so the next enforcing run succeeds. +`plm.exe` is the Windows-only legacy WPR trace helper for Learning Mode. It captures both `learningModeLogging` block events and `permissiveLearningMode` allow events, then delegates ETL decoding to the same canonical `learning_mode_windows::EtlDenialAnalyzer` used by `captureDenials`. -This PR introduces **capability extraction**: `EventID=14` DACL ACE blobs are decoded into AppContainer capability names via `extract_caps`, and those names are merged into `processContainer.capabilities`. UI relaxation arrives in a subsequent PR. +The canonical analyzer decodes filesystem, capability, registry, and UI findings from both provider shapes. The standalone `extract-caps` command remains available only as a low-level ACE diagnostic. PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and debugging the parser itself. @@ -10,9 +10,9 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo 1. **Capture** — `plm start` calls `wpr -start !AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector. 2. **Run** — the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied. -3. **Stop** — `plm stop` calls `wpr -stop ` and walks the `.etl` with `EvtQuery` / `EvtRender`. -4. **Parse** — for each `EventID=14`, the parser pulls the file path / access mask and decodes the DACL ACE blob into AppContainer capability names. `EventID=27` UI relaxation lands in a later PR. -5. **Merge** — file paths are added to `filesystem.readwritePaths` / `filesystem.readonlyPaths`; capability names are added to `processContainer.capabilities` (deduplicated case-insensitively against any capabilities already authored there, then sorted); results are written as `Adjusted_.json` next to the captured trace. +3. **Stop** — `plm stop` calls `wpr -stop ` and analyzes the sealed ETL through `EtlDenialAnalyzer`. +4. **Emit** — canonical findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths. +5. **Merge (temporary compatibility)** — file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it. > **Capability merge caveats.** Capabilities are only merged into a `processContainer` block — backends that cannot express AppContainer capabilities (LXC, Windows Sandbox, …) are left untouched and the discovered set is reported on stderr instead. The reserved names `learningModeLogging` and `permissiveLearningMode` are never written back, because `processContainer.capabilities` rejects them. @@ -24,8 +24,7 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo | `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` | | `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS/capability merge | | `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config | -| `src/event_parser.rs` | `EvtQuery` / `EvtRender` walk; shared `ParseAccumulator` + per-event dispatcher | -| `src/access_failure.rs` | `EventID=14` decoder: file-path normalization, post-XPath filters, ACE blob -> capabilities | +| `src/analysis.rs` | Canonical ETL analysis, denials JSON emission, and temporary config-generator adapter | | `src/access_event.rs` | `LearningModeAccessEvent` plain struct | | `src/extract_caps.rs` | DACL ACE blob decoder; resolves capability SIDs via `DeriveCapabilitySidsFromName` | | `src/config.rs` | JSON load/mutate; FS + capability merge into containment-backend section | @@ -53,10 +52,13 @@ Stops the active trace (or accepts a previously captured one). ```powershell plm.exe stop [--config-path ] [--log-dir ] [--bin-path ] - [--trace-file ] [--verbose-logging] + [--trace-file | --trace-output ] + [--exit-code ] [--verbose-logging] ``` -`--config-path` drives an in-memory merge of discovered file paths and capabilities against the input config and persists the result as `Adjusted_.json` in the log directory. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it to an arbitrary path, because `plm.exe` runs elevated and an operator-named output path would be an admin-privileged arbitrary-write primitive. The write is atomic (temp file in the same directory, then rename over the destination) so a downstream enforcing run never observes a truncated policy. +`--trace-output` selects the exact ETL destination passed to `wpr -stop`; it cannot be combined with `--trace-file`, which re-processes an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary. + +`--config-path` temporarily preserves the existing adjusted-config behavior. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it independently. The write is atomic so a downstream enforcing run never observes a truncated policy. ### `plm extract-caps` @@ -93,7 +95,7 @@ The WPR profile is embedded into `plm.exe` itself (see `src/profile_gen.rs`); on - **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent. - **Deny matching is enforced on literal, lexically-normalized paths only.** `config::normalize_path` strips verbatim/device prefixes, lowercases, collapses separators, and rejects ADS / `.` / `..`, but it is filesystem-free and does **not** resolve directory junctions, symlinks/reparse points, or 8.3 short names. 8.3 short-name aliases of a denied directory are detected lexically and refused promotion (fail-closed), but a junction/symlink alias (e.g. `C:\work\link` → `C:\Secrets`) is a lexically distinct path that will **not** match a deny entry and can therefore be promoted into the persisted `Adjusted_*.json`. Operators must deny the canonical target path; aliasing the target through a reparse point is a known gap. See the deny-matching code in `src/config.rs`. -- **No UI extraction yet.** `plm stop` writes `Adjusted_.json` with the discovered file paths and AppContainer capabilities. UI-policy extraction (`EventID=27`) arrives in a subsequent PR. +- The compatibility adjusted-config generator consumes file and capability denials only. UI regeneration moves to the shared opt-in regeneration engine; UI denials are already present in `denials.json`. ## See also diff --git a/src/host/plm/src/access_failure.rs b/src/host/plm/src/access_failure.rs deleted file mode 100644 index a662616c4..000000000 --- a/src/host/plm/src/access_failure.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! EventID=14 (access-failure) decode + consume. -//! -//! The Permissive-Learning-Mode provider emits one `EventID=14` per -//! file/capability access that *would* have been denied. This module -//! owns: -//! * the EventData property indices for that schema, -//! * file-path normalization (NT-object / verbatim / DOS-device -//! prefixes -> DOS form), -//! * the post-XPath filters (current-directory, drive-letter, -//! self-access, invalid filename chars), -//! * the per-event accumulator helper that feeds the DACL ACE blob -//! through `extract_caps` and pushes the resulting access event. -//! -//! `ParseAccumulator` (in `event_parser`) owns the mutable state; -//! `consume_access_failure` is the only public entry point. - -use crate::event_parser::{ParseAccumulator, ParsedEvent}; - -// File path we treat as "no useful info" and skip. -const MOUNT_POINT_MANAGER: &str = "\\Device\\MountPointManager"; - -// EventData property indexes for EventID=14 (matches the PowerShell -// parser's index map). -pub(crate) const FILE_PATH_INDEX: usize = 2; -const APP_PATH_INDEX: usize = 3; -const ACCESS_MASK_INDEX: usize = 5; - -/// Per-event consume helper for `EventID=14`. Walks the DACL ACE blob -/// through `extract_caps`, applies the post-XPath filters, and pushes a -/// `LearningModeAccessEvent` into `acc.valid_access_events` on success. -pub(crate) fn consume_access_failure(acc: &mut ParseAccumulator, mut ev: ParsedEvent) { - if let Some(idx) = ev.complex_data_4_idx { - // Borrow rather than clone — the ACE hex blob was already pushed - // by `parse_event_xml`; the other EventData slots taken below - // (0/1/3) live at different indices so this is safe. - if let Some(blob) = ev.event_data.get(idx) { - let blob_str = blob.as_str(); - if !blob_str.trim().is_empty() { - // Fail closed. The walker inserts matches as it goes, so - // a blob that is valid up to a corrupt tail would - // otherwise contribute capabilities from a record we - // know is malformed. Since the blob is - // attacker-influenceable and the output is a security - // policy, stage matches in a scratch set and promote - // them only if the entire walk succeeds; on failure the - // staged matches are dropped and the record is counted - // as data loss. - acc.ace_walk.matches.clear(); - let outcome = crate::extract_caps::extract_caps_into( - blob_str, - &acc.capability_index, - acc.verbose, - &mut acc.ace_walk, - ); - match outcome { - Ok(()) => { - // Promote the staged names. `drain` reuses the - // staging set's capacity for the next event, and - // the membership test means a `String` is - // allocated only the first time the trace sees a - // given capability. - for name in acc.ace_walk.matches.drain() { - if !acc.requested_capabilities.contains(name) { - acc.requested_capabilities.insert(name.to_string()); - } - } - } - Err(err) => { - acc.ace_walk.matches.clear(); - acc.parse_failures += 1; - if acc.verbose { - eprintln!( - "Failed to decode DACL ACE blob for an EventID=14 event; \ - discarding its capabilities: {err}" - ); - } - } - } - } - } - } - - // Pull the file path. Absent paths typically mean capability-only - // resource accesses whose capability has already been collected - // from the DACL above. Take the slot out via `mem::take` so we can - // normalise + trim in place without a second `String` allocation. - let mut file_path = match ev.event_data.get_mut(FILE_PATH_INDEX) { - Some(s) if !s.is_empty() => std::mem::take(s), - _ => return, - }; - - if file_path.eq_ignore_ascii_case(MOUNT_POINT_MANAGER) { - return; - } - - normalize_file_path_in_place(&mut file_path); - if acc.is_skippable(&file_path) { - return; - } - - // Skip self-events: the app accessing its own binary. ETW reports - // the accessed path in DOS form (`X:\dir\app.exe`) but the app's own - // path in volume-device form (`\Device\HarddiskVolumeN\dir\app.exe`), - // so we compare the *volume-relative* portion of each exactly. A raw - // `app_path.ends_with(tail)` suffix test produced false positives — - // e.g. an unrelated decoy `C:\app.exe` at the drive root matched a - // real `\Device\HarddiskVolume3\Tools\app.exe`, and any short path - // like `C:\exe` matched every `.exe` — silently dropping genuine - // events. An exact match on the root-relative path avoids both while - // still catching true self-access in any casing. - let app_path = ev - .event_data - .get_mut(APP_PATH_INDEX) - .map(std::mem::take) - .unwrap_or_default(); - if !app_path.is_empty() { - if let (Some(app_rel), Some(ev_rel)) = (volume_relative_path(&app_path), file_path.get(2..)) - { - if !ev_rel.is_empty() && app_rel.eq_ignore_ascii_case(ev_rel) { - return; - } - } - } - - if !looks_like_valid_path(&file_path) { - return; - } - - let access_mask = ev - .event_data - .get(ACCESS_MASK_INDEX) - .and_then(|s| parse_int_loose(s)) - .unwrap_or(0); - - if acc.verbose { - println!("{app_path}"); - println!("{file_path}"); - } - - trim_backslashes_in_place(&mut file_path); - - // Deduplicate on the (case-insensitive) file path and merge access - // masks. The provider emits the same denied access many times across - // a trace, and the same file is often touched with different masks - // (e.g. opened for read, later for write). Rather than push a fresh - // near-identical entry per occurrence — which on a large trace - // balloons `valid_access_events` with hundreds of thousands of - // redundant rows — keep one entry per unique path and OR each new - // mask into it. A file first read then written thus ends up - // correctly flagged read+write in a single entry. - let dedup_key = file_path.to_ascii_lowercase(); - if let Some(&idx) = acc.access_event_index.get(&dedup_key) { - acc.valid_access_events[idx].access_mask |= access_mask; - return; - } - acc.access_event_index - .insert(dedup_key, acc.valid_access_events.len()); - - acc.valid_access_events - .push(crate::access_event::LearningModeAccessEvent { - time_created: ev.time_created, - process_id: ev.process_id, - thread_id: ev.thread_id, - file_path, - access_mask, - }); -} - -/// Strip Windows path-namespace prefixes (`\??\`, `\\?\`, `\\.\`) so -/// downstream filters that expect a DOS form (`C:\...`) see one. -/// -/// All three prefixes are exactly 4 bytes; their leading and trailing -/// bytes are both `\\`, and the middle pair is `??`, `\?`, or `\.`. -/// Encoded as a 2-byte tuple match for clarity. -pub(crate) fn normalize_file_path_in_place(s: &mut String) { - let lead = s.len() - s.trim_start().len(); - if lead > 0 { - s.drain(..lead); - } - let end_len = s.trim_end().len(); - s.truncate(end_len); - - if s.len() >= 4 { - let h = s.as_bytes(); - let prefix_match = h[0] == b'\\' - && h[3] == b'\\' - && matches!((h[1], h[2]), (b'?', b'?') | (b'\\', b'?') | (b'\\', b'.')); - if prefix_match { - s.drain(..4); - } - } -} - -/// Strip leading + trailing `\` from a `String` in place. Mirrors -/// `str::trim_matches('\\')` without the `.to_string()` round-trip the -/// hot path used to do. -pub(crate) fn trim_backslashes_in_place(s: &mut String) { - let lead = s.len() - s.trim_start_matches('\\').len(); - if lead > 0 { - s.drain(..lead); - } - let end_len = s.trim_end_matches('\\').len(); - s.truncate(end_len); -} - -/// `Test-Path -IsValid` equivalent: reject control bytes and Windows -/// wildcards which the OS itself refuses. -pub(crate) fn looks_like_valid_path(path: &str) -> bool { - const BAD: &[char] = &['<', '>', '"', '|', '?', '*']; - !path.chars().any(|c| (c as u32) < 32 || BAD.contains(&c)) -} - -/// Accept decimal or `0x`-prefixed hex. -pub(crate) fn parse_int_loose(s: &str) -> Option { - let t = s.trim(); - if let Some(rest) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) { - u32::from_str_radix(rest, 16).ok() - } else { - t.parse::().ok() - } -} - -/// Reduce an application path to its *volume-relative* form — the path -/// from the volume root with a leading separator — so it can be compared -/// exactly against an event path's own root-relative portion -/// (`file_path.get(2..)`). Returns `None` for shapes we can't confidently -/// reduce, in which case the caller keeps the event rather than risk a -/// false self-access drop. -/// -/// * DOS form `X:\dir\app.exe` -> `\dir\app.exe` -/// * Device form `\Device\HarddiskVolumeN\dir\app.exe` -> `\dir\app.exe` -pub(crate) fn volume_relative_path(app_path: &str) -> Option<&str> { - // DOS form `X:\...`: strip the two-byte `X:` drive prefix, keeping - // the leading separator. - let bytes = app_path.as_bytes(); - if bytes.len() >= 2 && bytes[1] == b':' { - return app_path.get(2..); - } - // Volume-device form `\Device\HarddiskVolumeN\`: return the - // slice starting at the separator after the volume number so the - // result lines up with the DOS root-relative form above. - const VOL_PREFIX: &str = "\\Device\\HarddiskVolume"; - if app_path.len() > VOL_PREFIX.len() - && app_path[..VOL_PREFIX.len()].eq_ignore_ascii_case(VOL_PREFIX) - { - let after_prefix = &app_path[VOL_PREFIX.len()..]; - if let Some(sep) = after_prefix.find('\\') { - return Some(&after_prefix[sep..]); - } - } - None -} - -// ---- Test-only helpers --------------------------------------------------- - -/// Allocating sibling of `normalize_file_path_in_place`, kept for tests -/// that want a `&str` -> `String` API. The hot path uses the in-place -/// variant. -#[cfg(test)] -pub(crate) fn normalize_file_path(p: &str) -> String { - let mut s = p.to_string(); - normalize_file_path_in_place(&mut s); - s -} - -/// Test-only thin wrapper over [`ParseAccumulator::is_skippable`]. -/// -/// Building a throwaway accumulator here means the unit tests drive the -/// real production filter (the cached hot path in `event_parser`) instead -/// of a parallel copy of the logic that could silently drift out of -/// lock-step with it. -#[cfg(test)] -pub(crate) fn is_skippable( - file_path: &str, - current_directory: Option<&str>, - verbose: bool, -) -> bool { - // An empty capability index is fine: `is_skippable` only consults the - // cached CWD / drive-letter state, not the capability index. - crate::event_parser::ParseAccumulator::new( - current_directory, - verbose, - crate::extract_caps::CapabilityIndex::for_test(&[]), - ) - .is_skippable(file_path) -} - -/// Shared `EventID=14` XML fixture used by tests in this module and -/// by the mixed-stream integration test in `event_parser`. -#[cfg(test)] -pub(crate) fn make_event_xml(file_path: &str, mask_hex: &str) -> String { - format!( - r#" - - 14 - - - - - Permissive - File - {file_path} - App.exe - 0 - {mask_hex} - - "# - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::event_parser::parse_events_from_xml; - use crate::extract_caps::CapabilityIndex; - - /// These tests exercise path handling, not ACE matching, so they - /// inject an index that resolves nothing. - fn no_caps() -> CapabilityIndex { - CapabilityIndex::for_test(&[]) - } - - #[test] - fn normalize_file_path_strips_nt_object_prefix() { - assert_eq!(normalize_file_path("\\??\\C:\\foo"), "C:\\foo"); - assert_eq!(normalize_file_path("\\??\\c:\\foo"), "c:\\foo"); - assert_eq!(normalize_file_path("C:\\foo"), "C:\\foo"); - } - - /// Verbatim (`\\?\C:\...`) and DOS-device (`\\.\C:\...`) prefixes - /// must be stripped before `is_skippable`'s drive-letter gate; - /// otherwise the kernel provider's natural rendering of those - /// forms drops every event. - #[test] - fn normalize_file_path_strips_verbatim_and_dos_device_prefixes() { - // Each `\` doubles in a Rust string literal; on-disk path is - // `\\?\C:\foo`. - assert_eq!(normalize_file_path("\\\\?\\C:\\foo"), "C:\\foo"); - assert_eq!(normalize_file_path("\\\\.\\C:\\foo"), "C:\\foo"); - assert_eq!(normalize_file_path("\\\\?\\c:\\foo"), "c:\\foo"); - } - - /// After the prefix strip, a normalized path with a drive letter - /// must survive `is_skippable`. Integration between - /// `normalize_file_path` and the drive-letter gate. - #[test] - fn verbatim_prefix_path_survives_is_skippable() { - let normalized = normalize_file_path("\\\\?\\C:\\Users\\test\\foo.txt"); - assert!(!is_skippable(&normalized, None, false)); - } - - #[test] - fn is_skippable_rejects_short_and_non_drive_letter() { - assert!(is_skippable("abc", None, false)); - assert!(is_skippable("\\\\server\\share", None, false)); - assert!(!is_skippable("C:\\foo", None, false)); - } - - #[test] - fn is_skippable_filters_current_directory() { - assert!(is_skippable( - "C:\\repo\\src\\main.rs", - Some("C:\\repo"), - false - )); - assert!(!is_skippable( - "C:\\not-repo\\src\\main.rs", - Some("C:\\repo"), - false - )); - } - - /// A CWD of bare `C:\` (drive root) must NOT swallow every event - /// on that drive. Only an explicit equality match against the - /// drive root is honored. - #[test] - fn is_skippable_does_not_treat_drive_root_cwd_as_prefix() { - assert!(!is_skippable( - "C:\\Windows\\System32\\foo.dll", - Some("C:\\"), - false - )); - assert!(!is_skippable( - "C:\\Windows\\System32\\foo.dll", - Some("C:"), - false - )); - assert!(is_skippable("C:\\", Some("C:\\"), false)); - } - - #[test] - fn looks_like_valid_path_rejects_control_and_wildcards() { - assert!(!looks_like_valid_path("C:\\f\x00oo")); - assert!(!looks_like_valid_path("C:\\foo*")); - assert!(!looks_like_valid_path("C:\\foo?")); - assert!(looks_like_valid_path("C:\\foo\\bar.txt")); - } - - #[test] - fn parse_events_from_xml_accumulates_access_events() { - let xmls = [ - make_event_xml("C:\\Users\\test\\foo.txt", "0x1"), - make_event_xml("C:\\Users\\test\\bar.txt", "0x2"), - ]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert_eq!(result.valid_access_events.len(), 2); - assert_eq!( - result.valid_access_events[0].file_path, - "C:\\Users\\test\\foo.txt" - ); - assert_eq!(result.valid_access_events[0].access_mask, 0x1); - assert_eq!(result.valid_access_events[1].access_mask, 0x2); - } - - /// When a single rendered event is malformed we must not abort - /// the whole trace — every subsequent valid event would silently - /// disappear, leaving PLM under-granting on the next adjust pass. - /// The accumulator's `consume` swallows per-event parse failures; - /// this test pins that. - #[test] - fn parse_events_from_xml_skips_malformed_and_continues() { - let valid_a = make_event_xml("C:\\Users\\test\\a.txt", "0x1"); - let valid_b = make_event_xml("C:\\Users\\test\\b.txt", "0x2"); - let xmls: Vec = vec![ - valid_a, - "not xml".to_string(), - "".to_string(), - valid_b, - ]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert_eq!( - result.valid_access_events.len(), - 2, - "malformed events should be skipped, valid ones still collected" - ); - assert_eq!( - result.valid_access_events[0].file_path, - "C:\\Users\\test\\a.txt" - ); - assert_eq!( - result.valid_access_events[1].file_path, - "C:\\Users\\test\\b.txt" - ); - } - - /// EventData fixture with a caller-controlled `app_path` (index 3), - /// so the self-access dispatcher branch can be exercised. Mirrors - /// `make_event_xml`, which hard-codes a non-self `App.exe`. - fn make_event_xml_with_app(file_path: &str, app_path: &str, mask_hex: &str) -> String { - format!( - r#" - - 14 - - - - - Permissive - File - {file_path} - {app_path} - 0 - {mask_hex} - - "# - ) - } - - /// Dispatcher end-to-end: a `\Device\MountPointManager` record is - /// dropped before it can reach `valid_access_events`. - #[test] - fn consume_drops_mount_point_manager() { - let xmls = [make_event_xml("\\Device\\MountPointManager", "0x1")]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert!(result.valid_access_events.is_empty()); - } - - /// Dispatcher end-to-end: current-directory events are filtered, - /// while an unrelated path under a different root still passes. - #[test] - fn consume_skips_current_directory_but_keeps_others() { - let xmls = [ - make_event_xml("C:\\repo\\src\\main.rs", "0x1"), - make_event_xml("C:\\other\\x.txt", "0x1"), - ]; - let result = parse_events_from_xml(xmls.iter(), Some("C:\\repo"), false, no_caps()); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!(result.valid_access_events[0].file_path, "C:\\other\\x.txt"); - } - - /// Dispatcher end-to-end: too-short and non-drive-letter paths are - /// both dropped by the `is_skippable` gate. - #[test] - fn consume_skips_short_and_non_drive_letter() { - let xmls = [ - make_event_xml("abc", "0x1"), - make_event_xml("\\\\server\\share\\x", "0x1"), - ]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert!(result.valid_access_events.is_empty()); - } - - /// Dispatcher end-to-end: paths carrying invalid filename characters - /// (wildcards, control bytes) are rejected. - #[test] - fn consume_drops_invalid_filename_chars() { - let xmls = [make_event_xml("C:\\foo*bar.txt", "0x1")]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert!(result.valid_access_events.is_empty()); - } - - /// Self-access: an event whose path is the running app's own binary - /// is filtered. Covers both the volume-device (`\Device\...`) and - /// DOS (`X:\...`) spellings of `app_path`. - #[test] - fn consume_filters_true_self_access() { - let device = [make_event_xml_with_app( - "C:\\Tools\\app.exe", - "\\Device\\HarddiskVolume3\\Tools\\app.exe", - "0x1", - )]; - assert!(parse_events_from_xml(device.iter(), None, false, no_caps()) - .valid_access_events - .is_empty()); - - let dos = [make_event_xml_with_app( - "C:\\Tools\\app.exe", - "C:\\Tools\\app.exe", - "0x1", - )]; - assert!(parse_events_from_xml(dos.iter(), None, false, no_caps()) - .valid_access_events - .is_empty()); - - // Case-insensitive: a differently-cased spelling still matches. - let cased = [make_event_xml_with_app( - "C:\\Tools\\App.EXE", - "\\Device\\HarddiskVolume3\\tools\\app.exe", - "0x1", - )]; - assert!(parse_events_from_xml(cased.iter(), None, false, no_caps()) - .valid_access_events - .is_empty()); - } - - /// Regression for the old suffix-match self-access filter: a decoy - /// file that merely shares the app's *filename* at a different - /// location (`C:\app.exe` vs the real `...\Tools\app.exe`) must NOT - /// be dropped, because `\app.exe` != `\Tools\app.exe`. - #[test] - fn consume_keeps_same_name_decoy_at_different_location() { - let xmls = [make_event_xml_with_app( - "C:\\app.exe", - "\\Device\\HarddiskVolume3\\Tools\\app.exe", - "0x1", - )]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!(result.valid_access_events[0].file_path, "C:\\app.exe"); - } - - /// A normal valid event flows all the way through the dispatcher to - /// `valid_access_events` with its mask intact. - #[test] - fn consume_keeps_normal_valid_event() { - let xmls = [make_event_xml("C:\\Users\\test\\doc.txt", "0x1")]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!( - result.valid_access_events[0].file_path, - "C:\\Users\\test\\doc.txt" - ); - assert_eq!(result.valid_access_events[0].access_mask, 0x1); - } - - /// Repeated accesses to the same path (any casing) collapse to a - /// single entry whose mask is the OR of every observed mask, rather - /// than one near-identical entry per occurrence. - #[test] - fn consume_dedups_path_and_merges_masks() { - let xmls = [ - make_event_xml("C:\\Users\\test\\dup.txt", "0x1"), - make_event_xml("C:\\USERS\\TEST\\DUP.TXT", "0x2"), - make_event_xml("C:\\Users\\test\\dup.txt", "0x1"), - ]; - let result = parse_events_from_xml(xmls.iter(), None, false, no_caps()); - assert_eq!( - result.valid_access_events.len(), - 1, - "same path (case-insensitive) must collapse to one entry" - ); - assert_eq!( - result.valid_access_events[0].access_mask, 0x3, - "merged entry must OR every observed mask" - ); - } - - #[test] - fn volume_relative_path_reduces_device_and_dos_forms() { - assert_eq!( - volume_relative_path("\\Device\\HarddiskVolume3\\Tools\\app.exe"), - Some("\\Tools\\app.exe") - ); - assert_eq!( - volume_relative_path("C:\\Tools\\app.exe"), - Some("\\Tools\\app.exe") - ); - // Unrecognized shapes reduce to None so the caller keeps the - // event instead of risking a false self-access drop. - assert_eq!(volume_relative_path("App.exe"), None); - assert_eq!(volume_relative_path("\\Device\\Nul"), None); - } -} diff --git a/src/host/plm/src/analysis.rs b/src/host/plm/src/analysis.rs new file mode 100644 index 000000000..a26071e94 --- /dev/null +++ b/src/host/plm/src/analysis.rs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical Learning Mode analysis and compatibility views for `plm.exe`. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use learning_mode_core::{ + write_document, AccessType, AnalysisResult, DenialAnalyzer, DenialSummary, DenialsDocument, + DeniedResource, ResourceType, +}; +use learning_mode_windows::EtlDenialAnalyzer; + +use crate::access_event::LearningModeAccessEvent; + +/// Analyze a sealed ETL through the same decoder used by `captureDenials`. +pub fn analyze_trace(trace_file: &Path) -> Result { + EtlDenialAnalyzer + .analyze(trace_file) + .map_err(anyhow::Error::new) + .with_context(|| format!("failed to analyze {}", trace_file.display())) +} + +/// Write canonical denials JSON atomically. +pub fn write_denials(output_path: &Path, analysis: &AnalysisResult, exit_code: i32) -> Result<()> { + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + + let summary = DenialSummary::new( + exit_code, + analysis.denials.len(), + analysis.denied_resources_truncated, + ); + let document = DenialsDocument::new(analysis.denials.clone(), summary); + let mut temp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create denials temp file in {}", parent.display()))?; + write_document(&mut temp, &document) + .with_context(|| format!("failed to write {}", output_path.display()))?; + temp.flush() + .and_then(|_| temp.as_file().sync_all()) + .with_context(|| format!("failed to flush {}", output_path.display()))?; + temp.persist(output_path) + .map_err(|error| error.error) + .with_context(|| format!("failed to replace {}", output_path.display()))?; + Ok(()) +} + +/// Build the temporary legacy config-generator inputs from canonical denials. +/// +/// The adjusted-config generator is removed in the regeneration work item. +/// Until then, this adapter preserves its existing file/capability behavior +/// without retaining a second ETL parser. +pub fn legacy_config_inputs( + denials: &[DeniedResource], +) -> (Vec, HashSet) { + let mut events: Vec = Vec::new(); + let mut file_event_indices: HashMap = HashMap::new(); + let mut capabilities = HashSet::new(); + + for denial in denials { + match denial.resource_type { + ResourceType::File => { + if !is_local_drive_path(&denial.resource) { + continue; + } + let access_mask = match denial.access_type { + AccessType::Read => 0x1, + AccessType::Write => 0x2, + AccessType::Execute => 0x20, + AccessType::Unknown => continue, + }; + let key = denial.resource.to_ascii_lowercase(); + if let Some(index) = file_event_indices.get(&key).copied() { + events[index].access_mask |= access_mask; + } else { + file_event_indices.insert(key, events.len()); + events.push(LearningModeAccessEvent { + time_created: chrono::Utc::now(), + process_id: denial.pid, + thread_id: 0, + file_path: denial.resource.clone(), + access_mask, + }); + } + } + ResourceType::Capability => { + if !denial.resource.starts_with("S-1-") { + capabilities.insert(denial.resource.clone()); + } + } + ResourceType::Ui | ResourceType::Network | ResourceType::Other => {} + } + } + + (events, capabilities) +} + +fn is_local_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/') +} + +/// Print a concise human-readable view of canonical denials. +pub fn write_detection_summary(analysis: &AnalysisResult) { + println!(); + println!("Detected denials ({}):", analysis.denials.len()); + if analysis.denials.is_empty() { + println!(" (none)"); + } else { + for denial in &analysis.denials { + println!( + " [{:?}/{:?}] {}", + denial.resource_type, denial.access_type, denial.resource + ); + } + } + if analysis.denied_resources_truncated { + println!(" (truncated)"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn denial( + resource: &str, + resource_type: ResourceType, + access_type: AccessType, + ) -> DeniedResource { + DeniedResource { + resource: resource.to_string(), + resource_type, + access_type, + pid: 42, + filetime: 1, + } + } + + #[test] + fn legacy_inputs_are_derived_only_from_canonical_file_and_capability_denials() { + let denials = [ + denial(r"C:\read.txt", ResourceType::File, AccessType::Read), + denial(r"C:\write.txt", ResourceType::File, AccessType::Write), + denial(r"C:\both.txt", ResourceType::File, AccessType::Read), + denial(r"c:\BOTH.txt", ResourceType::File, AccessType::Write), + denial( + "internetClient", + ResourceType::Capability, + AccessType::Unknown, + ), + denial("Clipboard", ResourceType::Ui, AccessType::Unknown), + denial( + "S-1-15-3-1024-1-2-3-4-5-6-7-8", + ResourceType::Capability, + AccessType::Unknown, + ), + denial(r"C:\unknown.txt", ResourceType::File, AccessType::Unknown), + denial( + r"\\server\share\remote.txt", + ResourceType::File, + AccessType::Write, + ), + ]; + + let (events, capabilities) = legacy_config_inputs(&denials); + assert_eq!(events.len(), 3); + assert_eq!(events[0].access_mask, 0x1); + assert_eq!(events[1].access_mask, 0x2); + assert_eq!(events[2].access_mask, 0x3); + assert_eq!(capabilities, HashSet::from(["internetClient".to_string()])); + } + + #[test] + fn canonical_document_preserves_analysis_results() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("denials.json"); + let analysis = AnalysisResult { + denials: vec![denial(r"C:\read.txt", ResourceType::File, AccessType::Read)], + denied_resources_truncated: true, + }; + + write_denials(&path, &analysis, 7).unwrap(); + let document: DenialsDocument = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(document.denials, analysis.denials); + assert_eq!(document.summary.exit_code, 7); + assert!(document.summary.denied_resources_truncated); + } +} diff --git a/src/host/plm/src/config.rs b/src/host/plm/src/config.rs index 2b5313c9f..1b10b1b74 100644 --- a/src/host/plm/src/config.rs +++ b/src/host/plm/src/config.rs @@ -282,7 +282,7 @@ fn normalize_path(p: &str) -> Option { // 1. Strip verbatim / device prefix (`\\?\`, `\\.\`, and the // NT-object `\??\` prefix). UNC verbatim is rejected because // we don't grant policy to network shares. The `\??\` prefix - // must be stripped here too (not only in `event_parser`), so + // must be stripped here too (not only by the ETL analyzer), so // events that bypass that layer don't leak the literal prefix // into config storage. let stripped = strip_verbatim_or_device_prefix(p)?; @@ -1138,7 +1138,7 @@ mod tests { // ETW occasionally emits. `config::normalize_path` now // explicitly strips the `\??\` prefix (mirroring `\\?\` / // `\\.\` handling) so call sites that bypass - // `event_parser::normalize_file_path` still get a comparable + // the canonical ETL analyzer still gives us a comparable // drive-letter form. Without this, the self-event filter // would miss `\??\C:\plm\plm.exe`. assert_eq!(normalize_path("\\??\\C:\\foo").as_deref(), Some("c:\\foo")); diff --git a/src/host/plm/src/event_parser.rs b/src/host/plm/src/event_parser.rs deleted file mode 100644 index af305544f..000000000 --- a/src/host/plm/src/event_parser.rs +++ /dev/null @@ -1,1489 +0,0 @@ -//! Walks a sequence of WinEvent records produced by the permissive -//! learning-mode trace and returns the file-access events that survived -//! filtering, plus the AppContainer capabilities the workload was -//! observed to need. -//! -//! `EventID=14` records carry both a file path and a DACL ACE blob; the -//! blob is decoded by `crate::extract_caps` and accumulated into -//! `requested_capabilities`. UI relaxation (`EventID=27`) lands in a -//! later PR. - -use anyhow::Result; -use chrono::{DateTime, TimeZone, Utc}; -use quick_xml::events::{BytesStart, Event}; -use quick_xml::reader::Reader; -use std::collections::{HashMap, HashSet}; -#[cfg(target_os = "windows")] -use std::path::Path; - -#[cfg(target_os = "windows")] -use windows::core::{w, PCWSTR}; -#[cfg(target_os = "windows")] -use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS; -#[cfg(target_os = "windows")] -use windows::Win32::System::EventLog::{ - EvtClose, EvtNext, EvtQuery, EvtQueryFilePath, EvtQueryForwardDirection, EvtRender, - EvtRenderEventXml, EVT_HANDLE, -}; - -use crate::access_event::LearningModeAccessEvent; -use crate::extract_caps; - -/// EventID the PLM provider emits for a file/capability access that -/// *would* have been denied. Decoded by `crate::access_failure`. -pub(crate) const EVENT_ID_ACCESS_FAILURE: u32 = 14; -/// EventID the PLM provider emits for a UI-subsystem violation. -/// (Recognized by the XPath filter today; UI relaxation lands in a -/// later PR.) -pub(crate) const EVENT_ID_UI_VIOLATION: u32 = 27; - -/// RAII wrapper that calls `EvtClose` on drop. A panic or `?`-early -/// return inside the rendering loop no longer leaks kernel ETW handles. -#[cfg(target_os = "windows")] -struct EvtHandleOwned(EVT_HANDLE); - -#[cfg(target_os = "windows")] -impl Drop for EvtHandleOwned { - fn drop(&mut self) { - // SAFETY: `self.0` is an `EVT_HANDLE` this wrapper took - // ownership of at construction and never handed out; `EvtClose` - // is the correct release call and runs exactly once (on drop). - unsafe { - let _ = EvtClose(self.0); - } - } -} - -pub struct ParseResult { - pub valid_access_events: Vec, - /// AppContainer capability names decoded from the DACL ACE blobs of - /// `EventID=14` records. Only capabilities in the module's known - /// list, granted by a non-zero allow ACE, appear here. - pub requested_capabilities: HashSet, - /// Records that could not be parsed: malformed event XML, or an - /// `EventID=14` whose DACL ACE blob failed to decode. Surfaced so - /// partial data loss on a long trace is observable rather than - /// silent. - pub parse_failures: usize, - /// Allow ACEs that named a known capability but granted nothing, so - /// were not treated as capability requests. Reported once per trace - /// and surfaced here so the condition is assertable in tests rather - /// than depending on process-global warning state. - pub zero_mask_capabilities: usize, -} - -impl ParseResult { - /// True when the trace produced nothing mergeable into a config. - pub fn is_empty(&self) -> bool { - self.valid_access_events.is_empty() && self.requested_capabilities.is_empty() - } -} - -/// Abstraction over the native ETW query used by `for_each_event_xml`. -/// -/// Splitting the batch/render/handle-release control flow (in -/// `drive_event_stream`) from the raw `Evt*` FFI (in `NativeEtwSource`) -/// lets the loop's real behavior — multi-batch iteration, end-of-stream -/// vs. error distinction, render-failure skipping, and handle release -/// on every exit path — be exercised by a fake source in unit tests -/// without a live `.etl` trace. See the `etw_stream_tests` module. -/// -/// A handle returned by `next_batch` is owned by the driver until the -/// driver calls `close` on it exactly once (which the driver guarantees -/// even on early return or panic, via a batch-scoped drop guard). -#[cfg(any(target_os = "windows", test))] -trait EtwEventSource { - /// Opaque per-event handle. `Copy` so the driver can hold a batch in - /// a `Vec` and still hand each handle to `render`/`close` by value. - type Handle: Copy; - - /// Pull the next batch of up to `max` event handles. An empty `Vec` - /// signals end-of-stream (the native impl maps both a zero count and - /// `ERROR_NO_MORE_ITEMS` to this). Any `Err` is a real mid-stream - /// failure the driver propagates rather than treating as EOF. - fn next_batch(&mut self, max: usize) -> Result>; - - /// Render one event handle to its XML form, reusing `buf` as scratch - /// so the driver can amortize the allocation across the whole trace. - fn render(&self, handle: Self::Handle, buf: &mut Vec) -> Result; - - /// Release one event handle. Called exactly once for every handle - /// `next_batch` returned, including on the error/panic unwind paths. - fn close(&self, handle: Self::Handle); -} - -/// Drive an [`EtwEventSource`] to completion, invoking `on_xml` once per -/// successfully rendered event. This is the platform-independent core of -/// `for_each_event_xml`; the Windows path wraps a [`NativeEtwSource`], -/// and tests wrap a scripted fake. -/// -/// Semantics preserved from the original inlined loop: -/// * `next_batch` is called repeatedly until it yields an empty batch -/// (end of stream); traces larger than one batch are fully drained. -/// * A `next_batch` `Err` is a batch-level failure (no events at all) -/// and is propagated with context, since silently treating it as EOF -/// would look like a short-but-successful trace and under-grant. -/// * A `render` `Err` is a single unparsable record: it is counted and -/// skipped so one corrupt event can't discard every later access grant. -/// * An `on_xml` `Err` aborts the walk, but the batch drop guard still -/// releases every remaining handle in the current batch first. -#[cfg(any(target_os = "windows", test))] -fn drive_event_stream( - mut source: S, - batch_size: usize, - verbose: bool, - mut on_xml: F, -) -> Result<()> -where - S: EtwEventSource, - F: FnMut(&str) -> Result<()>, -{ - /// Owns a batch of handles and closes each one on drop, so an early - /// return from the loop body (an `on_xml` error) or a panic still - /// releases every handle in the current batch exactly once. - struct BatchGuard<'s, S: EtwEventSource> { - source: &'s S, - handles: Vec, - } - impl Drop for BatchGuard<'_, S> { - fn drop(&mut self) { - for &h in &self.handles { - self.source.close(h); - } - } - } - - // Reusable scratch buffer for `render` so we don't allocate a fresh - // Vec per event. - let mut render_buf: Vec = Vec::new(); - let mut rendered_count: usize = 0; - let mut render_failures: usize = 0; - loop { - let handles = source.next_batch(batch_size).map_err(|e| { - anyhow::anyhow!( - "EvtNext failed mid-stream (rendered {} events so far): {e}", - rendered_count - ) - })?; - if handles.is_empty() { - break; - } - - // Own all returned handles up front so every one is released on - // any exit path (normal completion, `on_xml` error, or panic). - let guard = BatchGuard { - source: &source, - handles, - }; - for &handle in &guard.handles { - // A single unrenderable event is skipped rather than - // aborting the whole trace: propagating here would discard - // every subsequent valid access grant and cause PLM to - // under-grant on the next run. - let xml = match source.render(handle, &mut render_buf) { - Ok(xml) => xml, - Err(e) => { - render_failures += 1; - if verbose { - eprintln!( - "Skipping unrenderable event (index {}, {} rendered / {} skipped so far): {e}", - rendered_count + render_failures, - rendered_count, - render_failures - ); - } - continue; - } - }; - on_xml(&xml)?; - rendered_count += 1; - } - } - if render_failures > 0 && verbose { - eprintln!( - "Event parsing finished: {} events rendered, {} unrenderable events skipped", - rendered_count, render_failures - ); - } - Ok(()) -} - -/// Live ETW source backing `for_each_event_xml`: owns the `EvtQuery` -/// handle and translates the driver's `next_batch`/`render`/`close` -/// calls into the corresponding `Evt*` FFI. -#[cfg(target_os = "windows")] -struct NativeEtwSource { - query: EvtHandleOwned, -} - -#[cfg(target_os = "windows")] -impl EtwEventSource for NativeEtwSource { - type Handle = EVT_HANDLE; - - fn next_batch(&mut self, max: usize) -> Result> { - let mut events: Vec = vec![0isize; max]; - let mut returned: u32 = 0; - // SAFETY: `self.query.0` is a live query handle owned by this - // source. `events` is a `max`-element buffer we own and pass by - // mutable slice; `EvtNext` writes at most `max` handles and - // reports the count through `returned`, which we own. - let next_ok = unsafe { - EvtNext( - self.query.0, - &mut events, - u32::MAX, // INFINITE - 0, - &mut returned as *mut _, - ) - }; - if let Err(e) = &next_ok { - // End-of-stream is reported as an error with this code; map - // it to an empty batch. Any other error is a real failure. - if e.code() == ERROR_NO_MORE_ITEMS.to_hresult() { - return Ok(Vec::new()); - } - return Err(anyhow::anyhow!("{e}")); - } - Ok(events - .iter() - .take(returned as usize) - .map(|&slot| EVT_HANDLE(slot)) - .collect()) - } - - fn render(&self, handle: EVT_HANDLE, buf: &mut Vec) -> Result { - render_event_xml(handle, buf) - } - - fn close(&self, handle: EVT_HANDLE) { - // SAFETY: `handle` is an event handle the driver received from - // `next_batch` and hands back to `close` exactly once; `EvtClose` - // is the correct release call for it. - unsafe { - let _ = EvtClose(handle); - } - } -} - -/// Stream every event matching the access-failure XPath query out of an -/// .etl file, invoking `on_xml` once per rendered event XML string. The -/// caller-supplied closure accumulates state; this keeps peak memory -/// bounded (the previous `Vec` buffer could run into multi-GB -/// on hour-long traces). The batch/render/handle-release semantics live -/// in [`drive_event_stream`]; this function only builds the live -/// [`NativeEtwSource`] the driver walks. -#[cfg(target_os = "windows")] -fn for_each_event_xml(trace_file: &Path, verbose: bool, on_xml: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - let path_w = wxc_common::string_util::to_wide(&trace_file.to_string_lossy()); - // The event-id filter is a compile-time constant, so bake it into - // the binary as a wide, NUL-terminated literal with `w!` rather than - // formatting + re-encoding it on every call. The literal must stay - // in sync with the `EVENT_ID_*` constants above. - const _: () = assert!(EVENT_ID_ACCESS_FAILURE == 14 && EVENT_ID_UI_VIOLATION == 27); - let query = w!("*[System[EventID=14 or EventID=27]]"); - - // SAFETY: `path_w` is a NUL-terminated wide buffer that outlives - // this call and `query` is a `'static` wide literal; the `PCWSTR`s - // borrow them for the duration of `EvtQuery`. The flags are valid - // `EvtQuery` bit constants. The returned handle is immediately - // adopted by `EvtHandleOwned` so it is closed on every exit path. - let h_query = EvtHandleOwned(unsafe { - EvtQuery( - None, - PCWSTR(path_w.as_ptr()), - query, - EvtQueryFilePath.0 | EvtQueryForwardDirection.0, - ) - }?); - - // `EvtNext` batch size is intentionally large to reduce user→kernel - // transitions on traces with tens of thousands of events. - const BATCH: usize = 256; - drive_event_stream(NativeEtwSource { query: h_query }, BATCH, verbose, on_xml) -} - -/// Convert a byte count reported by `EvtRender`'s `BufferUsed` / -/// `BufferSize` out-params into a u16 element count, rounding **up** so -/// a trailing odd byte still gets a slot rather than being truncated. -/// (`EvtRender` sizes are byte counts; our backing buffer is `Vec`.) -#[cfg(any(target_os = "windows", test))] -fn bytes_to_u16_ceil(bytes: usize) -> usize { - bytes.div_ceil(std::mem::size_of::()) -} - -/// Number of initialized u16s to expose (via `set_len`) after a -/// successful render: the reported byte count converted to whole u16s, -/// clamped to the buffer's capacity so we never claim more initialized -/// elements than the allocation holds. -#[cfg(any(target_os = "windows", test))] -fn rendered_len_u16(needed_bytes: usize, capacity_u16: usize) -> usize { - (needed_bytes / std::mem::size_of::()).min(capacity_u16) -} - -/// Trim a rendered UTF-16 buffer at the first NUL. `EvtRender` -/// NUL-terminates its XML output and reports the size *including* the -/// terminator, so the trailing NUL (and anything after it) must be -/// dropped before decoding. -#[cfg(any(target_os = "windows", test))] -fn trim_utf16_nul(buf: &[u16]) -> &[u16] { - match buf.iter().position(|&c| c == 0) { - Some(n) => &buf[..n], - None => buf, - } -} - -#[cfg(target_os = "windows")] -fn render_event_xml(event: EVT_HANDLE, buf: &mut Vec) -> Result { - use windows::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; - - // Keep `buf` at `len == 0` while `EvtRender` writes through the raw - // pointer using the explicit byte-size argument; only extend `len` - // to the returned u16 count on the SUCCESS path so callers reusing - // `render_buf` across events never observe uninitialized u16s. - // - // `clear()` runs BEFORE the reserve so that `Vec::reserve` — - // which guarantees `capacity ≥ len + additional`, not - // `capacity ≥ additional` — actually reaches the - // `INITIAL_GUESS_U16` target on the first call where `len` had - // been left non-zero by the previous event. - // - // `EvtRender` writes UTF-16, so the backing buffer is `Vec` - // to guarantee 2-byte alignment (`Vec` is only 1-byte-aligned - // and casting `.as_ptr()` to `*const u16` would be UB even on x86). - // Note: `EvtRender`'s `BufferSize` / `BufferUsed` parameters are - // BYTE counts, so multiply/divide by `size_of::()` at the - // Win32 boundary. - const INITIAL_GUESS_U16: usize = 4 * 1024; - buf.clear(); - if buf.capacity() < INITIAL_GUESS_U16 { - buf.reserve(INITIAL_GUESS_U16); - } - let cap_u16 = buf.capacity(); - let cap_bytes = cap_u16 * std::mem::size_of::(); - - let mut needed: u32 = 0; - let mut count: u32 = 0; - // SAFETY: `event` is a live rendered-event handle owned by the - // caller's `EvtHandleOwned`. `buf` has `capacity() == cap_u16` and - // `len == 0`; we pass its raw pointer with the matching byte size - // `cap_bytes`, so `EvtRender` writes only within the allocation. - // `needed`/`count` are owned out-params. - let first = unsafe { - EvtRender( - None, - event, - EvtRenderEventXml.0, - cap_bytes as u32, - Some(buf.as_mut_ptr() as *mut _), - &mut needed as *mut _, - &mut count as *mut _, - ) - }; - - if first.is_err() { - // ERROR_INSUFFICIENT_BUFFER means `needed` is now valid (in - // bytes); grow and retry once. Any other error is fatal. - // SAFETY: `GetLastError` reads the calling thread's last-error - // code set by the `EvtRender` call immediately above; it has no - // preconditions and no memory-safety implications. - let win_err = unsafe { GetLastError() }; - if win_err != ERROR_INSUFFICIENT_BUFFER { - return Err(anyhow::anyhow!( - "EvtRender failed (Win32 error {:?})", - win_err - )); - } - if needed == 0 { - return Err(anyhow::anyhow!("EvtRender returned zero size")); - } - let needed_u16 = bytes_to_u16_ceil(needed as usize); - if buf.capacity() < needed_u16 { - // `Vec::reserve(additional)` measures from `len`, not - // `capacity` — since `buf` is empty (cleared above), - // `additional == needed_u16` gets us `capacity ≥ needed_u16`. - buf.reserve(needed_u16); - } - let new_cap_u16 = buf.capacity(); - let new_cap_bytes = new_cap_u16 * std::mem::size_of::(); - // SAFETY: identical contract to the first `EvtRender` call, now - // with a buffer grown to `new_cap_bytes` (≥ `needed`) so the - // render fits. `buf` is still at `len == 0`. - let second = unsafe { - EvtRender( - None, - event, - EvtRenderEventXml.0, - new_cap_bytes as u32, - Some(buf.as_mut_ptr() as *mut _), - &mut needed as *mut _, - &mut count as *mut _, - ) - }; - // Propagate any error AFTER ensuring `buf` is still at len=0 - // (no uninit u16s exposed to the reused-buffer caller path). - second?; - } - - // `needed` is bytes written including the terminating NUL. - let init_u16 = rendered_len_u16(needed as usize, buf.capacity()); - // SAFETY: a successful `EvtRender` initialized `init_u16` u16s at - // the start of `buf` (clamped to `capacity()`), so extending `len` - // to `init_u16` exposes only initialized elements. - unsafe { - buf.set_len(init_u16); - } - let trimmed = trim_utf16_nul(buf); - Ok(String::from_utf16_lossy(trimmed)) -} - -/// Decoded XML view of a single event's interesting fields. -pub(crate) struct ParsedEvent { - pub(crate) event_id: u32, - pub(crate) time_created: DateTime, - pub(crate) process_id: u32, - pub(crate) thread_id: u32, - /// EventData/Data values in document order. May be Data or ComplexData. - pub(crate) event_data: Vec, - /// Index into `event_data` of the 5th `` sibling (the - /// DACL ACE blob on `EventID=14` access events). `None` if fewer - /// than five `ComplexData` children were seen. Borrowed directly - /// rather than cloned to avoid a second `String` allocation of the - /// largest per-event field. - pub(crate) complex_data_4_idx: Option, -} - -pub(crate) fn parse_event_xml(xml: &str) -> Option { - let mut reader = Reader::from_str(xml); - let mut acc = StreamAcc::default(); - - loop { - match reader.read_event() { - Ok(Event::Eof) => break, - // roxmltree rejected malformed input with `.ok()?`; mirror - // that by bailing to `None` on any reader error. - Err(_) => return None, - Ok(Event::Start(e)) => acc.open(&e, false), - Ok(Event::Empty(e)) => acc.open(&e, true), - Ok(Event::End(e)) => acc.close(e.local_name().as_ref()), - Ok(Event::Text(t)) => { - if acc.capture.is_some() { - if let Ok(raw) = std::str::from_utf8(t.as_ref()) { - if let Ok(s) = quick_xml::escape::unescape(raw) { - acc.push_text(&s); - } - } - } - } - Ok(Event::CData(t)) => { - if acc.capture.is_some() { - acc.push_text(&String::from_utf8_lossy(t.as_ref())); - } - } - _ => {} - } - } - - // `roxmltree` returned `None` when the `` element was - // absent (the `?` on `root.children().find(System)`); every other - // field carried a default. Preserve that single hard requirement. - if !acc.saw_system { - return None; - } - - Some(ParsedEvent { - event_id: acc.event_id, - time_created: acc - .time_created - .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap()), - process_id: acc.process_id, - thread_id: acc.thread_id, - event_data: acc.event_data, - complex_data_4_idx: acc.complex_data_4_idx, - }) -} - -/// Which leaf element's inner text the streaming parser is currently -/// accumulating. ``/`` are attribute-only and -/// never captured here. -enum Capture { - EventId, - Data { is_complex: bool }, -} - -/// Streaming replacement for the former per-event roxmltree DOM. Walks -/// the WinEvent record with a `quick-xml` pull parser, extracting only -/// the handful of fields the decoders consume and allocating a `String` -/// solely for those captured leaf texts — no document tree, no -/// intermediate node objects. Field semantics mirror the old DOM -/// lookups exactly, including "first element wins" and the `unwrap_or` -/// defaults. -#[derive(Default)] -struct StreamAcc { - saw_system: bool, - in_system: bool, - in_event_data: bool, - // `seen_*` guards reproduce roxmltree's `find(..)` first-match - // semantics: a second ``/``/`` - // must not overwrite the first, even when the first failed to parse. - seen_event_id: bool, - seen_time_created: bool, - seen_execution: bool, - event_id: u32, - time_created: Option>, - process_id: u32, - thread_id: u32, - event_data: Vec, - // Position of the 5th `` sibling (the DACL ACE blob), - // tracked instead of cloning that multi-KB text a second time. - complex_data_4_idx: Option, - complex_index: usize, - capture: Option<(Capture, String)>, -} - -impl StreamAcc { - fn open(&mut self, e: &BytesStart<'_>, is_empty: bool) { - let name = e.name(); - match name.local_name().as_ref() { - b"System" => { - self.saw_system = true; - if !is_empty { - self.in_system = true; - } - } - b"EventData" => { - if !is_empty { - self.in_event_data = true; - } - } - b"EventID" if self.in_system && !self.seen_event_id => { - self.seen_event_id = true; - if is_empty { - // Empty `` -> no text -> parse fails -> 0. - self.event_id = 0; - } else { - self.capture = Some((Capture::EventId, String::new())); - } - } - b"TimeCreated" if self.in_system && !self.seen_time_created => { - self.seen_time_created = true; - if let Some(v) = attr_value(e, b"SystemTime") { - if let Ok(dt) = DateTime::parse_from_rfc3339(&v) { - self.time_created = Some(dt.with_timezone(&Utc)); - } - } - } - b"Execution" if self.in_system && !self.seen_execution => { - self.seen_execution = true; - if let Some(n) = attr_value(e, b"ProcessID").and_then(|v| v.parse().ok()) { - self.process_id = n; - } - if let Some(n) = attr_value(e, b"ThreadID").and_then(|v| v.parse().ok()) { - self.thread_id = n; - } - } - b"Data" | b"ComplexData" if self.in_event_data => { - let is_complex = name.local_name().as_ref() == b"ComplexData"; - if is_empty { - self.finish_data(is_complex, String::new()); - } else { - self.capture = Some((Capture::Data { is_complex }, String::new())); - } - } - _ => {} - } - } - - fn push_text(&mut self, s: &str) { - if let Some((_, buf)) = &mut self.capture { - buf.push_str(s); - } - } - - fn close(&mut self, local: &[u8]) { - match local { - b"System" => self.in_system = false, - b"EventData" => self.in_event_data = false, - _ => {} - } - let matches = matches!( - (&self.capture, local), - (Some((Capture::EventId, _)), b"EventID") - | (Some((Capture::Data { .. }, _)), b"Data" | b"ComplexData") - ); - if !matches { - return; - } - let (kind, text) = self.capture.take().unwrap(); - match kind { - Capture::EventId => self.event_id = text.parse::().unwrap_or(0), - Capture::Data { is_complex } => self.finish_data(is_complex, text), - } - } - - fn finish_data(&mut self, is_complex: bool, text: String) { - let pushed_idx = self.event_data.len(); - self.event_data.push(text); - if is_complex { - if self.complex_index == 4 { - self.complex_data_4_idx = Some(pushed_idx); - } - self.complex_index += 1; - } - } -} - -/// Read a single attribute's unescaped value as an owned `String`. -fn attr_value(e: &BytesStart<'_>, name: &[u8]) -> Option { - let a = e.try_get_attribute(name).ok().flatten()?; - let raw = std::str::from_utf8(a.value.as_ref()).ok()?; - quick_xml::escape::unescape(raw) - .ok() - .map(|v| v.into_owned()) -} - -/// Mutable per-trace accumulator. Fields are `pub(crate)` so the -/// sibling event-type decoders can write into them directly without an -/// inflated method surface. -pub(crate) struct ParseAccumulator { - /// Cached lowercase form of the trace's current directory with - /// trailing `\\` trimmed (computed once at construction so the hot - /// `is_skippable` path doesn't allocate two `String`s per event). - /// `None` only when `current_directory` is `None`; a bare drive root - /// is still retained here for the exact-equality match. - pub(crate) cwd_lc_trimmed: Option, - /// Cached lowercase `"{cwd}\\"` prefix used for the under-CWD match. - /// `None` when `current_directory` is `None` or is a bare drive root - /// (a drive-root prefix would swallow every path on that volume). - pub(crate) cwd_lc_prefix: Option, - pub(crate) verbose: bool, - pub(crate) valid_access_events: Vec, - /// Maps a normalized (lowercased) file path to the index of its - /// entry in `valid_access_events`, so repeated access failures for - /// the same file collapse to a single entry whose `access_mask` is - /// the OR of every observed mask. The provider emits the same denied - /// access many times across a trace, and a file is frequently - /// touched with different masks (read, then write); without this a - /// long trace balloons `valid_access_events` — and the generated - /// config — with hundreds of thousands of redundant near-identical - /// entries. - pub(crate) access_event_index: HashMap, - pub(crate) requested_capabilities: HashSet, - /// Count of events whose XML failed to parse in `consume` (i.e. - /// `parse_event_xml` returned `None`). A malformed record is skipped - /// rather than aborting the trace, but the running total is surfaced - /// at the end of a parse so silent data loss is observable. - pub(crate) parse_failures: usize, - pub(crate) capability_index: extract_caps::CapabilityIndex, - /// Per-trace scratch and diagnostics for the ACE walk. - /// - /// Holds the reusable hex-decode buffer, the per-event staging set, - /// and the zero-mask counter. Staged matches are borrowed - /// `&'static str`, so a repeated capability costs nothing until it - /// is first promoted into `requested_capabilities`. - /// - /// The staging set is the fail-closed boundary: the ACE walk inserts - /// as it goes, so a blob that is valid up to a corrupt tail would - /// otherwise contribute its already-matched capabilities even though - /// the record is malformed. These blobs are attacker-influenceable - /// and the output is a security policy, so matches land here first - /// and are promoted only once the whole blob walks cleanly. - pub(crate) ace_walk: extract_caps::AceWalkState, -} - -impl ParseAccumulator { - pub(crate) fn new( - current_directory: Option<&str>, - verbose: bool, - capability_index: extract_caps::CapabilityIndex, - ) -> Self { - let (cwd_lc_trimmed, cwd_lc_prefix) = match current_directory { - Some(cwd) => { - let trimmed = cwd.trim_end_matches('\\'); - // A bare drive root is exactly two bytes: an ASCII letter - // followed by ':' (e.g. "C:"). Inspecting the bytes directly - // — rather than folding an `Option` from `chars().next()` - // down to `false` — states that intent plainly and drops an - // unwrap whose fallback is unreachable once the length is - // known to be 2. - let trimmed_bytes = trimmed.as_bytes(); - let is_drive_root = trimmed_bytes.len() == 2 - && trimmed_bytes[0].is_ascii_alphabetic() - && trimmed_bytes[1] == b':'; - let lc = trimmed.to_ascii_lowercase(); - let prefix = if is_drive_root { - None - } else { - Some(format!("{lc}\\")) - }; - (Some(lc), prefix) - } - None => (None, None), - }; - Self { - cwd_lc_trimmed, - cwd_lc_prefix, - verbose, - valid_access_events: Vec::new(), - access_event_index: HashMap::new(), - requested_capabilities: HashSet::new(), - parse_failures: 0, - capability_index, - ace_walk: extract_caps::AceWalkState::new(), - } - } - - /// Hot-path CWD / drive-letter filter for access events. Uses - /// precomputed lowercase forms of `current_directory` to avoid two - /// `String` allocs per event. - pub(crate) fn is_skippable(&self, file_path: &str) -> bool { - if let (Some(cwd_lowercase), cwd_prefix) = (&self.cwd_lc_trimmed, &self.cwd_lc_prefix) { - let normalized_path = file_path.trim_end_matches('\\'); - let path_bytes = normalized_path.as_bytes(); - let cwd_bytes = cwd_lowercase.as_bytes(); - let matches_cwd_exactly = path_bytes.len() == cwd_bytes.len() - && path_bytes - .iter() - .zip(cwd_bytes) - .all(|(path_byte, cwd_byte)| path_byte.eq_ignore_ascii_case(cwd_byte)); - let is_under_cwd = cwd_prefix - .as_deref() - .map(|prefix| { - let prefix_bytes = prefix.as_bytes(); - path_bytes.len() >= prefix_bytes.len() - && path_bytes[..prefix_bytes.len()] - .iter() - .zip(prefix_bytes) - .all(|(path_byte, prefix_byte)| { - path_byte.eq_ignore_ascii_case(prefix_byte) - }) - }) - .unwrap_or(false); - if matches_cwd_exactly || is_under_cwd { - if self.verbose { - println!("Skipping current-directory event: {file_path}"); - } - return true; - } - } - if file_path.len() < 4 { - if self.verbose { - println!("Skipping too-short path event: {file_path}"); - } - return true; - } - let second = file_path.chars().nth(1); - if second != Some(':') { - if self.verbose { - println!("Skipping non-drive-letter path event: {file_path}"); - } - return true; - } - false - } - - /// Per-event entry point. Decodes the XML, dispatches by event id, - /// and silently swallows malformed records (so a bad event mid-trace - /// doesn't abort the rest). EventID=27 (UI violation) is recognized - /// by the XPath filter today but contributes no relaxation until the - /// UI-policy PR. - fn consume(&mut self, xml: &str) { - let Some(ev) = parse_event_xml(xml) else { - self.parse_failures += 1; - if self.verbose { - eprintln!("Warning: skipping malformed event record (could not parse XML)"); - } - return; - }; - match ev.event_id { - EVENT_ID_ACCESS_FAILURE => crate::access_failure::consume_access_failure(self, ev), - EVENT_ID_UI_VIOLATION => { - // UI-violation dispatch arrives in a later PR. - } - _ => {} - } - } - - /// Write the end-of-parse diagnostics. - /// - /// Takes a writer rather than calling `eprintln!` directly so the - /// operator-visible text and its counts are assertable in a test and - /// cannot go silent in a later refactor. - fn write_parse_diagnostics(&self, out: &mut dyn std::io::Write) { - if self.parse_failures > 0 { - let _ = writeln!( - out, - "Warning: skipped {} malformed event record(s) that could not be parsed", - self.parse_failures - ); - } - let zero_mask = self.ace_walk.zero_mask_capabilities(); - if zero_mask > 0 { - let _ = writeln!( - out, - "warning: {zero_mask} allow ACE(s) named a known capability with a zero access \ - mask and were not treated as capability requests. If capabilities are missing \ - from the generated config, this filter is the first thing to check." - ); - } - } - - fn into_result(self) -> ParseResult { - self.write_parse_diagnostics(&mut std::io::stderr()); - ParseResult { - valid_access_events: self.valid_access_events, - requested_capabilities: self.requested_capabilities, - parse_failures: self.parse_failures, - zero_mask_capabilities: self.ace_walk.zero_mask_capabilities(), - } - } -} - -#[cfg(target_os = "windows")] -pub fn parse_events( - trace_file: &Path, - current_directory: Option<&str>, - verbose: bool, - capability_index: extract_caps::CapabilityIndex, -) -> Result { - let mut acc = ParseAccumulator::new(current_directory, verbose, capability_index); - for_each_event_xml(trace_file, verbose, |xml| { - acc.consume(xml); - Ok(()) - })?; - Ok(acc.into_result()) -} - -/// Fixture-test seam: drive the same per-event accumulator -/// `parse_events` uses, but pull XML strings from an iterator rather -/// than a live ETW session. Pass `CapabilityIndex::for_test(&[])` when -/// ACE matching isn't under test. -pub fn parse_events_from_xml( - xmls: I, - current_directory: Option<&str>, - verbose: bool, - capability_index: extract_caps::CapabilityIndex, -) -> ParseResult -where - I: IntoIterator, - S: AsRef, -{ - let mut acc = ParseAccumulator::new(current_directory, verbose, capability_index); - for xml in xmls { - acc.consume(xml.as_ref()); - } - acc.into_result() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::access_failure::{make_event_xml, FILE_PATH_INDEX}; - - const ACCESS_MASK_INDEX: usize = 5; - - #[test] - fn parse_event_xml_extracts_event_id_and_data() { - let xml = r#" - - 14 - - - - - Permissive - File - C:\Users\test\foo.txt - App.exe - 0 - 0x1 - - "#; - let ev = parse_event_xml(xml).expect("xml should parse"); - assert_eq!(ev.event_id, 14); - assert_eq!(ev.process_id, 111); - assert_eq!(ev.thread_id, 222); - assert_eq!(ev.event_data.len(), 6); - assert_eq!(ev.event_data[FILE_PATH_INDEX], "C:\\Users\\test\\foo.txt"); - assert_eq!(ev.event_data[ACCESS_MASK_INDEX], "0x1"); - } - - #[test] - fn parse_event_xml_returns_none_for_malformed() { - assert!(parse_event_xml("not xml").is_none()); - assert!(parse_event_xml("").is_none()); - } - - #[test] - fn parse_events_from_xml_drives_access_failure_dispatch() { - // Single fs-only event; ensure the dispatcher runs and the - // event is collected. - let xml = make_event_xml("C:\\app\\foo.txt", "0x1"); - let result = parse_events_from_xml( - vec![xml], - None, - false, - extract_caps::CapabilityIndex::for_test(&[]), - ); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!(result.valid_access_events[0].file_path, "C:\\app\\foo.txt"); - } - - // ---- end-to-end: XML -> ACE blob -> requested_capabilities ---------- - // - // The positional handoff (`complex_data_4_idx` = the 5th - // `` sibling) is the contract between the provider's - // schema and capability extraction, and it is the piece most likely - // to break silently if the provider reorders its payload. Every - // other test in this crate either feeds ``-only XML (so the - // blob is never reached) or calls the extractor directly (so the - // XML layer is bypassed). These drive the whole path. - - /// S-1-1-0 "Everyone" — stands in for a capability SID. - fn e2e_sid() -> Vec { - vec![1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0] - } - - /// Build one ACE in the layout `extract_caps` decodes. - fn e2e_ace(mask: u32, sid: &[u8]) -> Vec { - let mut v = vec![0u8]; // ACCESS_ALLOWED - v.extend_from_slice(&[0, 0, 0]); // padding - v.extend_from_slice(&[0, 0, 0, 0]); // flags - v.extend_from_slice(&mask.to_le_bytes()); - v.extend_from_slice(sid); - v - } - - fn e2e_hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02X}")).collect() - } - - fn e2e_index(name: &'static str, sid: &[u8]) -> extract_caps::CapabilityIndex { - extract_caps::CapabilityIndex::for_test(&[(name, Some(sid), None)]) - } - - /// `EventID=14` XML carrying `complex_count` `` - /// siblings, the last of which holds `blob_hex`. - fn event_xml_with_complex_data( - file_path: &str, - mask_hex: &str, - complex_count: usize, - blob_hex: &str, - ) -> String { - let mut complex = String::new(); - for i in 0..complex_count { - let body = if i + 1 == complex_count { - blob_hex - } else { - "00" - }; - complex.push_str(&format!("{body}")); - } - format!( - r#" - - 14 - - - - - Permissive - File - {file_path} - App.exe - 0 - {mask_hex} - {complex} - - "# - ) - } - - #[test] - fn fifth_complex_data_sibling_populates_requested_capabilities() { - let sid = e2e_sid(); - let hex = e2e_hex(&e2e_ace(0x0012_0089, &sid)); - let xml = event_xml_with_complex_data("C:\\app\\foo.txt", "0x1", 5, &hex); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!( - result.requested_capabilities.contains("internetClient"), - "the 5th blob should have produced a capability, got {:?}", - result.requested_capabilities - ); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!(result.parse_failures, 0); - } - - #[test] - fn capability_only_event_without_file_path_still_yields_capability() { - // Capability-only accesses arrive with an empty path. The event - // produces no access-event row, but its capability must still - // be collected — this is why extraction runs before the - // path-based early return. - let sid = e2e_sid(); - let hex = e2e_hex(&e2e_ace(0x0012_0089, &sid)); - let xml = event_xml_with_complex_data("", "0x1", 5, &hex); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!(result.requested_capabilities.contains("internetClient")); - assert!(result.valid_access_events.is_empty()); - } - - #[test] - fn fewer_than_five_complex_data_siblings_yields_no_capability() { - // The blob lives in the 5th sibling; with only four present - // there is nothing to decode and the event must still be - // processed normally rather than mis-indexing into another slot. - let sid = e2e_sid(); - let hex = e2e_hex(&e2e_ace(0x0012_0089, &sid)); - let xml = event_xml_with_complex_data("C:\\app\\foo.txt", "0x1", 4, &hex); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!( - result.requested_capabilities.is_empty(), - "no 5th means no capability blob" - ); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!(result.parse_failures, 0); - } - - #[test] - fn malformed_ace_blob_is_counted_as_a_parse_failure() { - // A corrupt blob must not vanish silently: the file-path half of - // the event still succeeds, so without the counter the lost - // capabilities would leave no trace in the output. - let sid = e2e_sid(); - let xml = event_xml_with_complex_data("C:\\app\\foo.txt", "0x1", 5, "ZZZZ"); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!(result.requested_capabilities.is_empty()); - assert_eq!(result.valid_access_events.len(), 1); - assert_eq!( - result.parse_failures, 1, - "a malformed ACE blob should be counted" - ); - } - - #[test] - fn valid_ace_with_truncated_tail_contributes_no_capabilities() { - // Fail-closed: the blob is attacker-influenceable and the output - // is a security policy, so a record that walks partway and then - // hits a corrupt trailer must contribute nothing at all — not - // the capability it managed to match before failing. - let sid = e2e_sid(); - let mut bytes = e2e_ace(0x0012_0089, &sid); - bytes.extend_from_slice(&[0u8; 6]); // truncated trailing ACE - let xml = event_xml_with_complex_data("C:\\app\\foo.txt", "0x1", 5, &e2e_hex(&bytes)); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!( - result.requested_capabilities.is_empty(), - "a partially-valid blob must not leak capabilities into policy, got {:?}", - result.requested_capabilities - ); - assert_eq!(result.parse_failures, 1); - } - - #[test] - fn staging_set_does_not_leak_between_events() { - // The staging set is reused across events; a failed record must - // not poison the next one, and a good record after a bad one - // must still be collected. - let sid = e2e_sid(); - let good = e2e_hex(&e2e_ace(0x0012_0089, &sid)); - let mut bad_bytes = e2e_ace(0x0012_0089, &sid); - bad_bytes.extend_from_slice(&[0u8; 6]); - let bad = e2e_hex(&bad_bytes); - - let xmls = vec![ - event_xml_with_complex_data("C:\\app\\bad.txt", "0x1", 5, &bad), - event_xml_with_complex_data("C:\\app\\good.txt", "0x1", 5, &good), - ]; - - let result = parse_events_from_xml(xmls, None, false, e2e_index("internetClient", &sid)); - - assert_eq!(result.parse_failures, 1); - assert!( - result.requested_capabilities.contains("internetClient"), - "the following good event must still contribute" - ); - assert_eq!(result.requested_capabilities.len(), 1); - } - - #[test] - fn capabilities_dedupe_across_multiple_events() { - let sid = e2e_sid(); - let hex = e2e_hex(&e2e_ace(0x0012_0089, &sid)); - let xmls: Vec = (0..3) - .map(|i| event_xml_with_complex_data(&format!("C:\\app\\f{i}.txt"), "0x1", 5, &hex)) - .collect(); - - let result = parse_events_from_xml(xmls, None, false, e2e_index("internetClient", &sid)); - - assert_eq!(result.requested_capabilities.len(), 1); - assert_eq!(result.valid_access_events.len(), 3); - } - - #[test] - fn every_record_malformed_yields_an_empty_result_and_no_config_output() { - // A trace where nothing decodes must be loudly empty, not - // quietly partial: `stop` keys its "skip writing Adjusted_*.json" - // decision off `is_empty()`, so a regression that let junk - // through here would emit a config derived from garbage. - let xmls = vec![ - "not xml".to_string(), - "".to_string(), - // Well-formed XML, but no element — the one hard - // requirement `parse_event_xml` enforces. - "x".to_string(), - String::new(), - ]; - let expected_failures = xmls.len(); - - let result = parse_events_from_xml( - xmls, - None, - false, - extract_caps::CapabilityIndex::for_test(&[]), - ); - - assert_eq!( - result.parse_failures, expected_failures, - "every malformed record must be counted" - ); - assert!(result.valid_access_events.is_empty()); - assert!(result.requested_capabilities.is_empty()); - assert!( - result.is_empty(), - "is_empty() gates whether stop writes an Adjusted_*.json at all" - ); - } - - #[test] - fn parse_diagnostics_report_failures_and_zero_mask_counts() { - // The counters are covered elsewhere; this pins the - // operator-visible text so a refactor cannot make parse failures - // silent. Written through a captured writer rather than stderr. - let mut acc = - ParseAccumulator::new(None, false, extract_caps::CapabilityIndex::for_test(&[])); - acc.parse_failures = 3; - - let mut out = Vec::new(); - acc.write_parse_diagnostics(&mut out); - let text = String::from_utf8(out).expect("diagnostics must be UTF-8"); - - assert!( - text.contains("skipped 3 malformed event record(s)"), - "parse-failure count must be reported verbatim: {text}" - ); - } - - #[test] - fn parse_diagnostics_are_silent_on_a_clean_trace() { - let acc = ParseAccumulator::new(None, false, extract_caps::CapabilityIndex::for_test(&[])); - let mut out = Vec::new(); - acc.write_parse_diagnostics(&mut out); - assert!( - out.is_empty(), - "a clean parse must not emit warning noise: {}", - String::from_utf8_lossy(&out) - ); - } - - #[test] - fn zero_mask_capabilities_are_reported_and_surfaced_on_the_result() { - // A zero-mask allow ACE is filtered out, but silently dropping - // it would look identical to "this capability was never - // requested" — so the count reaches both the operator and the - // caller. - let sid = e2e_sid(); - let hex = e2e_hex(&e2e_ace(0, &sid)); - let xml = event_xml_with_complex_data("C:\\app\\foo.txt", "0x1", 5, &hex); - - let result = - parse_events_from_xml(vec![xml], None, false, e2e_index("internetClient", &sid)); - - assert!(result.requested_capabilities.is_empty()); - assert_eq!( - result.zero_mask_capabilities, 1, - "the filtered ACE must be counted on the result" - ); - } -} - -/// Coverage for the ETW stream driver ([`drive_event_stream`]) and the -/// buffer-sizing arithmetic in the render path — the pieces MGudgin -/// flagged as having zero test coverage because every other test feeds -/// synthetic XML straight into `parse_events_from_xml`, bypassing the -/// live `EvtQuery`/`EvtNext`/`EvtRender` walk. -/// -/// The native `Evt*` FFI can't run without a real `.etl` trace, so the -/// loop is exercised through a scripted [`FakeEtwSource`] that stands in -/// for the ETW query: it reproduces multi-batch traces (>256 events), -/// end-of-stream detection, batch-level `EvtNext` failures, per-event -/// `EvtRender` failures, and — critically — lets the test assert every -/// handle is released even when the consumer errors partway through a -/// batch. The pure buffer-sizing helpers are tested directly. -#[cfg(test)] -mod etw_stream_tests { - use super::*; - use std::cell::RefCell; - use std::collections::{HashMap, VecDeque}; - use std::rc::Rc; - - type Recorder = Rc>>; - - /// A scripted stand-in for the native ETW query. `batches` is the - /// sequence returned by successive `next_batch` calls (an empty - /// `Vec` — or exhausting the queue — signals end-of-stream; an `Err` - /// simulates a mid-stream `EvtNext` failure). `render_outcomes` maps - /// a handle id to the XML it renders, or to a simulated `EvtRender` - /// failure. `closed`/`rendered` are shared with the test via `Rc`, - /// so handle-release and render ordering can be asserted even though - /// `drive_event_stream` consumes the source by value. - struct FakeEtwSource { - batches: VecDeque, String>>, - render_outcomes: HashMap>, - closed: Recorder, - rendered: Recorder, - } - - impl FakeEtwSource { - /// Returns the source plus the shared `(rendered, closed)` - /// recorders the test reads after the walk completes. - fn new( - batches: Vec, String>>, - render_outcomes: HashMap>, - ) -> (Self, Recorder, Recorder) { - let rendered: Recorder = Rc::new(RefCell::new(Vec::new())); - let closed: Recorder = Rc::new(RefCell::new(Vec::new())); - let source = Self { - batches: batches.into(), - render_outcomes, - closed: Rc::clone(&closed), - rendered: Rc::clone(&rendered), - }; - (source, rendered, closed) - } - } - - impl EtwEventSource for FakeEtwSource { - type Handle = u64; - - fn next_batch(&mut self, _max: usize) -> Result> { - match self.batches.pop_front() { - None => Ok(Vec::new()), - Some(Ok(handles)) => Ok(handles), - Some(Err(msg)) => Err(anyhow::anyhow!(msg)), - } - } - - fn render(&self, handle: u64, _buf: &mut Vec) -> Result { - self.rendered.borrow_mut().push(handle); - match self.render_outcomes.get(&handle) { - Some(Ok(xml)) => Ok(xml.clone()), - Some(Err(msg)) => Err(anyhow::anyhow!(msg.clone())), - None => Ok(format!("")), - } - } - - fn close(&self, handle: u64) { - self.closed.borrow_mut().push(handle); - } - } - - #[test] - fn drains_multiple_batches_over_256_events() { - // Two batches totalling 300 events, then end-of-stream. The - // original inlined loop used a fixed 256-element array; this - // proves the driver keeps calling `next_batch` until it drains a - // trace larger than a single batch, and releases every handle. - let first: Vec = (0..256).collect(); - let second: Vec = (256..300).collect(); - let (source, _rendered, closed) = - FakeEtwSource::new(vec![Ok(first), Ok(second)], HashMap::new()); - - let seen = RefCell::new(Vec::::new()); - let result = drive_event_stream(source, 256, false, |xml| { - seen.borrow_mut().push(xml.to_string()); - Ok(()) - }); - - assert!(result.is_ok()); - assert_eq!( - seen.borrow().len(), - 300, - "every event across both batches renders" - ); - assert_eq!(seen.borrow()[0], ""); - assert_eq!(seen.borrow()[299], ""); - assert_eq!(closed.borrow().len(), 300, "every handle is released"); - } - - #[test] - fn empty_first_batch_is_end_of_stream() { - let (source, _rendered, closed) = FakeEtwSource::new(vec![Ok(Vec::new())], HashMap::new()); - let mut calls = 0usize; - let result = drive_event_stream(source, 256, false, |_xml| { - calls += 1; - Ok(()) - }); - assert!(result.is_ok()); - assert_eq!(calls, 0, "no events delivered for an empty trace"); - assert!(closed.borrow().is_empty(), "no handles to release"); - } - - #[test] - fn next_batch_error_propagates_with_context_and_closes_prior_handles() { - // First batch succeeds, second batch fails mid-stream. The error - // must propagate (not be treated as EOF) and carry the - // rendered-so-far count, and the first batch's handles must have - // been released before the failure surfaces. - let (source, _rendered, closed) = FakeEtwSource::new( - vec![ - Ok(vec![10, 11]), - Err("simulated EvtNext failure".to_string()), - ], - HashMap::new(), - ); - - let result = drive_event_stream(source, 256, false, |_xml| Ok(())); - - let err = result.expect_err("mid-stream EvtNext failure must propagate"); - let msg = format!("{err}"); - assert!( - msg.contains("EvtNext failed mid-stream"), - "unexpected error message: {msg}" - ); - assert!( - msg.contains("rendered 2 events so far"), - "error should report the rendered-so-far count: {msg}" - ); - let mut closed_ids = closed.borrow().clone(); - closed_ids.sort_unstable(); - assert_eq!( - closed_ids, - vec![10, 11], - "first batch's handles are released before the failure propagates" - ); - } - - #[test] - fn render_failure_is_skipped_and_stream_continues() { - // A single unrenderable event in the middle of a batch must be - // skipped, not abort the whole trace — and all three handles - // (including the one that failed to render) must still be closed. - let mut outcomes = HashMap::new(); - outcomes.insert(20u64, Ok("".to_string())); - outcomes.insert(21u64, Err("simulated EvtRender failure".to_string())); - outcomes.insert(22u64, Ok("".to_string())); - let (source, _rendered, closed) = FakeEtwSource::new(vec![Ok(vec![20, 21, 22])], outcomes); - - let seen = RefCell::new(Vec::::new()); - let result = drive_event_stream(source, 256, false, |xml| { - seen.borrow_mut().push(xml.to_string()); - Ok(()) - }); - - assert!(result.is_ok(), "one bad render must not fail the walk"); - assert_eq!( - *seen.borrow(), - vec![ - "".to_string(), - "".to_string() - ], - "the unrenderable middle event is skipped, the rest survive" - ); - let mut closed_ids = closed.borrow().clone(); - closed_ids.sort_unstable(); - assert_eq!( - closed_ids, - vec![20, 21, 22], - "the unrenderable event's handle is still released" - ); - } - - #[test] - fn on_xml_error_releases_every_handle_in_the_batch() { - // The reviewer's key case: the consumer errors partway through a - // batch. The walk must abort, but the batch drop guard must still - // release EVERY handle in the batch (including the one that - // errored and the ones after it) — no ETW handle leak on the - // error path. - let (source, _rendered, closed) = - FakeEtwSource::new(vec![Ok(vec![30, 31, 32])], HashMap::new()); - - let result = drive_event_stream(source, 256, false, |xml| { - if xml.contains("id=\"31\"") { - Err(anyhow::anyhow!("consumer rejected event 31")) - } else { - Ok(()) - } - }); - - assert!(result.is_err(), "an on_xml error aborts the walk"); - let mut closed_ids = closed.borrow().clone(); - closed_ids.sort_unstable(); - assert_eq!( - closed_ids, - vec![30, 31, 32], - "every handle in the batch is released even though on_xml errored on 31" - ); - } - - // ---- pure buffer-sizing arithmetic (the `EvtRender` growth path) ---- - - #[test] - fn bytes_to_u16_ceil_rounds_up_odd_trailing_byte() { - assert_eq!(bytes_to_u16_ceil(0), 0); - assert_eq!(bytes_to_u16_ceil(1), 1); - assert_eq!(bytes_to_u16_ceil(2), 1); - assert_eq!(bytes_to_u16_ceil(3), 2); - assert_eq!(bytes_to_u16_ceil(4), 2); - // A large "oversized render" byte count still converts cleanly. - assert_eq!(bytes_to_u16_ceil(16_384), 8_192); - } - - #[test] - fn rendered_len_u16_clamps_to_capacity() { - // Reported bytes fit inside the buffer: expose exactly that many - // whole u16s. - assert_eq!(rendered_len_u16(100, 4096), 50); - // Reported bytes exceed capacity (defensive clamp so `set_len` - // never claims uninitialized elements past the allocation). - assert_eq!(rendered_len_u16(16_384, 4096), 4096); - // Exact fit. - assert_eq!(rendered_len_u16(8192, 4096), 4096); - } - - #[test] - fn trim_utf16_nul_stops_at_first_terminator() { - let no_nul: Vec = "abc".encode_utf16().collect(); - assert_eq!(trim_utf16_nul(&no_nul), no_nul.as_slice()); - - let mut with_nul: Vec = "ab".encode_utf16().collect(); - with_nul.push(0); - with_nul.extend("garbage".encode_utf16()); - assert_eq!( - trim_utf16_nul(&with_nul), - "ab".encode_utf16().collect::>().as_slice(), - "everything from the NUL onward is dropped" - ); - - let leading_nul = [0u16, b'x' as u16]; - assert!(trim_utf16_nul(&leading_nul).is_empty()); - } -} diff --git a/src/host/plm/src/extract_caps.rs b/src/host/plm/src/extract_caps.rs index aee8cbe8c..d8d6443b0 100644 --- a/src/host/plm/src/extract_caps.rs +++ b/src/host/plm/src/extract_caps.rs @@ -619,8 +619,7 @@ pub(crate) fn parse_hex_string_into(hex_input: &str, out: &mut Vec) -> Resul // ACE blobs per trace that added up. // // iterate `as_bytes()` rather than `chars()`. The - // input is always ASCII hex emitted by the Windows event renderer - // (`` text nodes from EvtRender), so per-codepoint + // input is ASCII hex copied from an ETW diagnostic payload, so per-codepoint // UTF-8 decoding is pure overhead. Non-hex / non-whitespace bytes // still surface the same error. out.clear(); @@ -729,8 +728,7 @@ fn read_ace_at_offset(buf: &[u8], cursor: usize) -> Result> { /// names in `found` *and* returns `Err`. Callers that feed a security /// policy must therefore treat `Err` as fail-closed: stage per blob and /// discard on error rather than pointing this at an accumulated set. -/// See `access_failure::consume_access_failure`, which stages into -/// `AceWalkState::matches` and only promotes on `Ok`. +/// Production callers must similarly stage matches and only promote on `Ok`. fn walk_aces( buf: &[u8], index: &CapabilityIndex, @@ -1215,7 +1213,7 @@ mod tests { #[test] fn truncated_tail_writes_partially_so_callers_must_stage() { // Pins the low-level contract that motivates fail-closed - // staging in `consume_access_failure`: the walker DOES leave + // staging in production callers: the walker DOES leave // matches behind on error, which is exactly why a caller must // not point it at an accumulated policy set. let sid = well_world_sid(); diff --git a/src/host/plm/src/lib.rs b/src/host/plm/src/lib.rs index 574c43d47..ffd533a03 100644 --- a/src/host/plm/src/lib.rs +++ b/src/host/plm/src/lib.rs @@ -6,10 +6,10 @@ //! gated per-module. The `plm` binary in `main.rs` is Windows-only. pub mod access_event; -pub mod access_failure; +#[cfg(target_os = "windows")] +pub mod analysis; pub mod config; pub mod coordination; -pub mod event_parser; pub mod extract_caps; pub mod profile_gen; diff --git a/src/host/plm/src/log.rs b/src/host/plm/src/log.rs index 32efce25e..7347fef9b 100644 --- a/src/host/plm/src/log.rs +++ b/src/host/plm/src/log.rs @@ -14,11 +14,11 @@ use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; +use crate::analysis::{analyze_trace, legacy_config_inputs, write_detection_summary}; use crate::config::{ deny_file_set, initialize_filesystem, update_from_access_events, write_added_paths_summary, }; use crate::coordination::PLM_LOG_START_IN_FLIGHT; -use crate::event_parser::parse_events; use crate::start; use crate::stop::{stop_plm_trace_with, WprExeStopper}; use std::sync::atomic::Ordering; @@ -72,18 +72,14 @@ pub fn run( println!("Beginning event parsing, this may take several minutes"); } - let cwd = std::env::current_dir() - .ok() - .map(|p| p.to_string_lossy().trim_end_matches('\\').to_string()); - // Discover capability SIDs here, at the CLI boundary, so the parse - // itself is deterministic and can be driven with an injected index. - let capability_index = crate::extract_caps::discover_capabilities(verbose); - let parse = parse_events(&trace_file, cwd.as_deref(), verbose, capability_index); + let analysis = analyze_trace(&trace_file); - // Clean up the temp .etl regardless of parse outcome. + // Clean up the temp .etl regardless of analysis outcome. let _ = std::fs::remove_file(&trace_file); - let parse = parse?; + let analysis = analysis?; + write_detection_summary(&analysis); + let (valid_access_events, _) = legacy_config_inputs(&analysis.denials); // Synthesize a blank config and run the FS merge to preview what a // policy authored from scratch would receive. Capability and UI @@ -96,13 +92,8 @@ pub fn run( // that will never match a real event's file path. let bin_path = String::from("\\\\plm-blank-config-bin-sentinel"); - let added = update_from_access_events( - &mut blank, - &bin_path, - &parse.valid_access_events, - &deny, - verbose, - )?; + let added = + update_from_access_events(&mut blank, &bin_path, &valid_access_events, &deny, verbose)?; write_added_paths_summary(&added, verbose); diff --git a/src/host/plm/src/main.rs b/src/host/plm/src/main.rs index 17bf4b06a..5d7f8b1a1 100644 --- a/src/host/plm/src/main.rs +++ b/src/host/plm/src/main.rs @@ -258,6 +258,12 @@ enum Cmd { /// supplied file is parsed as-is. #[arg(long)] trace_file: Option, + /// Exact destination for the ETL produced by `wpr -stop`. + #[arg(long, conflicts_with = "trace_file")] + trace_output: Option, + /// Workload exit code to record in the canonical denials JSON. + #[arg(long, default_value_t = 0)] + exit_code: i32, /// Emit per-event/per-ACE diagnostic output. #[arg(long)] verbose_logging: bool, @@ -426,19 +432,25 @@ fn main() -> Result<()> { bin_path, config_path, trace_file, + trace_output, + exit_code, verbose_logging, } => { let _singleton = acquire_singleton_if_needed()?; - stop::run( + let result = stop::run( stop::StopOptions { log_dir, bin_path, config_path, trace_file, + trace_output, + exit_code, verbose: verbose_logging, }, &exe, - ) + )?; + println!("{}", serde_json::to_string(&result)?); + Ok(()) } Cmd::ExtractCaps { hex_bytes, diff --git a/src/host/plm/src/start.rs b/src/host/plm/src/start.rs index 6321002f7..0d5149a79 100644 --- a/src/host/plm/src/start.rs +++ b/src/host/plm/src/start.rs @@ -294,6 +294,12 @@ mod tests { provider (GUID 811a1ddb-2e69-5f25-adc0-4b186170e760); without it the \ event-id=14/27 detection pipeline has nothing to consume", ); + assert!( + wprp.contains("EP_Microsoft-Windows-Kernel-General") + && wprp.contains("a68ca8b7-004f-d7b6-a698-07e2de0f1f5d"), + "EMBEDDED_WPRP must enable Microsoft-Windows-Kernel-General for \ + learningModeLogging block events", + ); // The profile also wires the kernel collector for process/loader // events the parser uses to attribute access failures to a diff --git a/src/host/plm/src/stop.rs b/src/host/plm/src/stop.rs index a580817e5..cc0b876f5 100644 --- a/src/host/plm/src/stop.rs +++ b/src/host/plm/src/stop.rs @@ -6,15 +6,18 @@ use anyhow::{Context, Result}; use chrono::Local; +use serde::Serialize; use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use crate::analysis::{ + analyze_trace, legacy_config_inputs, write_denials, write_detection_summary, +}; use crate::config::{ deny_file_set, initialize_filesystem, load_config, merge_capabilities, resolve_adjusted_config_path, save_adjusted_config, update_from_access_events, - write_added_paths_summary, write_detection_summary, write_requested_capabilities_summary, + write_added_paths_summary, write_requested_capabilities_summary, }; -use crate::event_parser::parse_events; use crate::wpr_path::wpr_command; pub struct StopOptions { @@ -25,9 +28,94 @@ pub struct StopOptions { /// captured trace. Useful for re-processing a previously captured /// trace without an active WPR session. pub trace_file: Option, + /// Exact destination passed to `wpr -stop`. + pub trace_output: Option, + /// Exit code recorded in the canonical denials document. + pub exit_code: i32, pub verbose: bool, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StopResult { + pub trace_path: PathBuf, + pub denials_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub adjusted_config_path: Option, +} + +#[derive(Debug)] +struct ConfigOutputPaths { + source: PathBuf, + snapshot: PathBuf, + adjusted: PathBuf, +} + +fn resolve_trace_path( + trace_file: Option<&Path>, + trace_output: Option<&Path>, + log_dir: &Path, +) -> Result<(PathBuf, bool)> { + match (trace_file, trace_output) { + (Some(_), Some(_)) => { + anyhow::bail!("--trace-file and --trace-output cannot be used together") + } + (Some(path), None) => Ok((path.to_path_buf(), true)), + (None, Some(path)) => Ok((path.to_path_buf(), false)), + (None, None) => Ok((log_dir.join("trace.etl"), false)), + } +} + +fn prepare_config_output_paths( + config_path: Option<&Path>, + log_dir: &Path, + trace_path: &Path, + denials_path: &Path, +) -> Result> { + if same_config_target(trace_path, denials_path) { + anyhow::bail!( + "trace output {} would be overwritten by denials output {}", + trace_path.display(), + denials_path.display() + ); + } + + let Some(source) = config_path else { + return Ok(None); + }; + let leaf = source + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "config.json".into()); + let snapshot = log_dir.join(leaf); + let adjusted = resolve_adjusted_config_path(&snapshot)?; + + for (label, path) in [ + ("source config", source), + ("config snapshot", snapshot.as_path()), + ("adjusted config", adjusted.as_path()), + ] { + if same_config_target(path, trace_path) || same_config_target(path, denials_path) { + anyhow::bail!( + "{label} path {} collides with a capture output", + path.display() + ); + } + } + if same_config_target(source, &adjusted) || same_config_target(&snapshot, &adjusted) { + anyhow::bail!( + "adjusted config output {} collides with a source or snapshot config", + adjusted.display() + ); + } + + Ok(Some(ConfigOutputPaths { + source: source.to_path_buf(), + snapshot, + adjusted, + })) +} + /// Abstraction over `wpr -stop` invocations so the failure-mapping /// state machine in `stop_plm_trace_with` is testable without /// actually spawning processes. Mirrors `start::WprLauncher`. @@ -93,11 +181,16 @@ pub fn resolve_bin_path(opt: Option<&Path>, exe_dir: &Path) -> (PathBuf, Option< } } -pub fn run(opts: StopOptions, exe_dir: &Path) -> Result<()> { +pub fn run(opts: StopOptions, exe_dir: &Path) -> Result { // $LogDir defaults to "\logs\_pid". // Including PID + sub-second component avoids collisions when // parallel PLM tasks finish in the same second. let log_dir = opts.log_dir.unwrap_or_else(|| { + if let Some(parent) = opts.trace_output.as_deref().and_then(Path::parent) { + if !parent.as_os_str().is_empty() { + return parent.to_path_buf(); + } + } let stamp = format!( "{}_pid{}", Local::now().format("%Y-%m-%d_%H%M%S%.3f"), @@ -117,48 +210,58 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result<()> { eprintln!("[plm] warning: {w}"); } - let trace_file = if let Some(p) = opts.trace_file.as_ref() { + let (trace_file, is_existing_trace) = resolve_trace_path( + opts.trace_file.as_deref(), + opts.trace_output.as_deref(), + &log_dir, + )?; + let denials_path = log_dir.join("denials.json"); + let config_outputs = prepare_config_output_paths( + opts.config_path.as_deref(), + &log_dir, + &trace_file, + &denials_path, + )?; + + if is_existing_trace { // Operator supplied a pre-captured .etl -- don't try to stop a // (likely non-existent) live WPR session. - if !p.exists() { - anyhow::bail!("trace file does not exist: {}", p.display()); + if !trace_file.exists() { + anyhow::bail!("trace file does not exist: {}", trace_file.display()); } - p.clone() } else { - let p = log_dir.join("trace.etl"); - stop_plm_trace(&p)?; - p - }; + if let Some(parent) = trace_file.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + } + stop_plm_trace(&trace_file)?; + } if opts.verbose { println!("Beginning event parsing, this may take several minutes"); } - // Current directory at parse time -- events under this path are - // treated as test scaffolding noise and skipped. - let cwd = std::env::current_dir() - .ok() - .map(|p| p.to_string_lossy().trim_end_matches('\\').to_string()); - - // Discover capability SIDs here, at the CLI boundary, so the parse - // itself is deterministic and can be driven with an injected index. - let capability_index = crate::extract_caps::discover_capabilities(opts.verbose); - let parse = parse_events(&trace_file, cwd.as_deref(), opts.verbose, capability_index)?; - - write_detection_summary(&parse.valid_access_events, &parse.requested_capabilities); - write_requested_capabilities_summary(&parse.requested_capabilities, opts.verbose); - - let config_path = match opts.config_path.as_ref() { - Some(p) => p, - None => return Ok(()), + let analysis = analyze_trace(&trace_file)?; + write_detection_summary(&analysis); + write_denials(&denials_path, &analysis, opts.exit_code)?; + + let config_outputs = match config_outputs { + Some(paths) => paths, + None => { + return Ok(StopResult { + trace_path: trace_file, + denials_path, + adjusted_config_path: None, + }) + } }; - // Load the source config into memory FIRST, before any disk - // side effect touches the log directory. If the source is - // unreadable or malformed we want to bail before we've - // produced a half-populated log_dir (bare trace.etl + no - // config, no adjusted). - let base_config = load_config(config_path)?; + // Load the source config before copying or mutating it. The trace and + // canonical denials remain useful even if this compatibility-only + // adjusted-config phase fails. + let base_config = load_config(&config_outputs.source)?; // Copy the original config alongside the trace unconditionally // so operators always have a snapshot of the exact input that @@ -167,23 +270,39 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result<()> { // attempt any edit-and-save cycle below: it's the operator's // only record of the pre-edit state, and losing it turns an // Adjusted_*.json into an un-auditable delta. - let leaf = config_path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "config.json".into()); - let dest_config = log_dir.join(&leaf); - std::fs::copy(config_path, &dest_config) - .with_context(|| format!("failed to copy {}", config_path.display()))?; + if !same_config_target(&config_outputs.source, &config_outputs.snapshot) { + std::fs::copy(&config_outputs.source, &config_outputs.snapshot) + .with_context(|| format!("failed to copy {}", config_outputs.source.display()))?; + } + + if analysis.denied_resources_truncated { + eprintln!( + "[plm] warning: denial analysis was truncated; skipping adjusted-config \ + generation because the learned policy would be incomplete" + ); + return Ok(StopResult { + trace_path: trace_file, + denials_path, + adjusted_config_path: None, + }); + } + + let (valid_access_events, requested_capabilities) = legacy_config_inputs(&analysis.denials); + write_requested_capabilities_summary(&requested_capabilities, opts.verbose); - if parse.is_empty() { + if valid_access_events.is_empty() && requested_capabilities.is_empty() { // Nothing mergeable -- skip producing an Adjusted_*.json (which // would be byte-identical to the input and confuse the harness's // diff-based pass/fail signal). - return Ok(()); + return Ok(StopResult { + trace_path: trace_file, + denials_path, + adjusted_config_path: None, + }); } // Edit the pre-loaded copy of the config in memory rather than - // re-reading `dest_config` — this avoids a read-after-write on + // re-reading the snapshot — this avoids a read-after-write on // Windows where an AV filter can occasionally serve a stale or // empty buffer for a file that `std::fs::copy` just wrote. let mut config = base_config; @@ -194,35 +313,19 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result<()> { let added = update_from_access_events( &mut config, &bin_path_s, - &parse.valid_access_events, + &valid_access_events, &deny, opts.verbose, )?; - if !parse.requested_capabilities.is_empty() { - merge_capabilities(&mut config, &parse.requested_capabilities)?; - } - - let adjusted = resolve_adjusted_config_path(&dest_config)?; - - // Enforce the invariant that the comment above `dest_config` relies - // on: the adjusted output must never clobber the operator's input - // snapshot. The derived `Adjusted_` name can't collide today, - // but check canonically so any future spelling (`.`/`..`, 8.3, or a - // symlinked alias of the same file) is caught rather than assumed - // impossible. - if same_config_target(&adjusted, &dest_config) { - anyhow::bail!( - "adjusted config path {} would overwrite the input snapshot {}", - adjusted.display(), - dest_config.display() - ); + if !requested_capabilities.is_empty() { + merge_capabilities(&mut config, &requested_capabilities)?; } // Create the parent directory here — propagating any error — rather // than silently inside the (now pure) resolver. A missing parent is // surfaced instead of swallowed. - if let Some(parent) = adjusted.parent() { + if let Some(parent) = config_outputs.adjusted.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent).with_context(|| { format!( @@ -233,23 +336,49 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result<()> { } } - save_adjusted_config(&config, &adjusted)?; + save_adjusted_config(&config, &config_outputs.adjusted)?; write_added_paths_summary(&added, opts.verbose); - Ok(()) + Ok(StopResult { + trace_path: trace_file, + denials_path, + adjusted_config_path: Some(config_outputs.adjusted), + }) } -/// True iff `a` and `b` denote the same file. Compares canonically when -/// both already exist (resolving `.`/`..`, 8.3, and symlink aliases); -/// falls back to a lexical comparison when either side doesn't exist -/// yet (the adjusted output typically doesn't). `dest_config` always -/// exists at the call site, so the canonical arm fires whenever the -/// adjusted path also resolves to an existing file. +/// True iff `a` and `b` denote the same Windows target. +/// +/// Existing files are canonicalized directly. For a not-yet-created output, +/// the existing parent is canonicalized before the leaf is reattached, which +/// still resolves junctions, symlinks, short names, and `.`/`..`. The final +/// comparison is case-insensitive because Windows paths are case-insensitive. fn same_config_target(a: &Path, b: &Path) -> bool { - match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { - (Ok(ca), Ok(cb)) => ca == cb, - _ => a == b, - } + target_comparison_key(a) == target_comparison_key(b) +} + +fn target_comparison_key(path: &Path) -> String { + let resolved = std::fs::canonicalize(path) + .or_else(|_| { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()); + let parent = parent.unwrap_or_else(|| Path::new(".")); + let canonical_parent = std::fs::canonicalize(parent)?; + Ok::<_, std::io::Error>(match path.file_name() { + Some(file_name) => canonical_parent.join(file_name), + None => canonical_parent, + }) + }) + .or_else(|_| std::path::absolute(path)) + .unwrap_or_else(|_| path.to_path_buf()); + + let key = resolved.to_string_lossy().replace('/', "\\"); + let key = key + .strip_prefix(r"\\?\UNC\") + .map(|rest| format!(r"\\{rest}")) + .or_else(|| key.strip_prefix(r"\\?\").map(str::to_string)) + .unwrap_or(key); + key.to_ascii_lowercase() } #[cfg(test)] @@ -355,6 +484,47 @@ mod tests { ); } + #[test] + fn trace_output_is_used_as_the_exact_wpr_destination() { + let log_dir = Path::new(r"C:\logs"); + let output = Path::new(r"D:\captures\block.etl"); + let (path, existing) = resolve_trace_path(None, Some(output), log_dir).unwrap(); + assert_eq!(path, output); + assert!(!existing); + } + + #[test] + fn trace_input_and_output_are_mutually_exclusive() { + let error = resolve_trace_path( + Some(Path::new("input.etl")), + Some(Path::new("output.etl")), + Path::new("."), + ) + .unwrap_err(); + assert!(error.to_string().contains("cannot be used together")); + } + + #[test] + fn trace_output_cannot_collide_with_denials_output() { + let path = Path::new(r"C:\captures\denials.json"); + let error = + prepare_config_output_paths(None, Path::new(r"C:\captures"), path, path).unwrap_err(); + assert!(error.to_string().contains("would be overwritten")); + } + + #[test] + fn source_config_collision_is_rejected_before_capture() { + let trace = Path::new(r"C:\captures\trace.etl"); + let error = prepare_config_output_paths( + Some(trace), + Path::new(r"C:\logs"), + trace, + Path::new(r"C:\logs\denials.json"), + ) + .unwrap_err(); + assert!(error.to_string().contains("source config")); + } + #[test] fn same_config_target_matches_identical_existing_path() { // Two spellings of the same existing file must be detected as @@ -375,4 +545,14 @@ mod tests { ); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn same_config_target_is_case_insensitive_for_new_outputs() { + let dir = std::env::temp_dir().join(format!("plm_case_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let lower = dir.join("denials.json"); + let upper = dir.join("DENIALS.JSON"); + assert!(same_config_target(&lower, &upper)); + let _ = std::fs::remove_dir_all(&dir); + } } From a1bf1558b9371963e0a5563d7cbfbfb68c694246 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 7 Aug 2026 14:12:13 -0700 Subject: [PATCH 02/50] Preserve PLM integration semantics Forward audit workload exit codes, avoid policy previews from truncated analysis, and retain the legacy current-directory exclusion in the temporary adjusted-config adapter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/core/wxc/src/main.rs | 38 +++++++++++++++++++++++---- src/host/plm/src/analysis.rs | 50 ++++++++++++++++++++++++++++++++++-- src/host/plm/src/log.rs | 32 ++++++++++++++++++++++- src/host/plm/src/stop.rs | 6 ++++- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index ccac609b5..bd1952f95 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -533,6 +533,21 @@ fn config_file_path(cli: &Cli) -> Option { .map(std::path::PathBuf::from) } +#[cfg(target_os = "windows")] +fn audit_stop_args( + config_path: Option<&std::path::Path>, + exit_code: i32, +) -> Vec { + let mut args = vec![std::ffi::OsString::from("stop")]; + if let Some(config_path) = config_path { + args.push(std::ffi::OsString::from("--config-path")); + args.push(config_path.as_os_str().to_owned()); + } + args.push(std::ffi::OsString::from("--exit-code")); + args.push(std::ffi::OsString::from(exit_code.to_string())); + args +} + #[cfg(target_os = "windows")] use audit::{ cancel_active_audit_trace, mark_audit_active, release_audit_singleton, run_plm_command, @@ -1313,11 +1328,7 @@ fn main() { // `Drop` runs `wpr -cancel` for us. #[cfg(target_os = "windows")] if cli.audit { - let mut stop_args: Vec = vec![std::ffi::OsString::from("stop")]; - if let Some(cfg) = audit_config_file.as_ref() { - stop_args.push(std::ffi::OsString::from("--config-path")); - stop_args.push(cfg.clone().into_os_string()); - } + let stop_args = audit_stop_args(audit_config_file.as_deref(), response.exit_code); let borrowed: Vec<&std::ffi::OsStr> = stop_args .iter() .map(std::ffi::OsString::as_os_str) @@ -1490,6 +1501,23 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[test] + fn audit_stop_args_include_workload_exit_code() { + let args = audit_stop_args(Some(std::path::Path::new(r"C:\config.json")), 23); + assert_eq!( + args, + [ + "stop", + "--config-path", + r"C:\config.json", + "--exit-code", + "23" + ] + .map(std::ffi::OsString::from) + ); + } + #[test] fn state_aware_dispatch_errors_use_only_auxiliary_diagnostic_sinks() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/host/plm/src/analysis.rs b/src/host/plm/src/analysis.rs index a26071e94..98babc648 100644 --- a/src/host/plm/src/analysis.rs +++ b/src/host/plm/src/analysis.rs @@ -56,6 +56,7 @@ pub fn write_denials(output_path: &Path, analysis: &AnalysisResult, exit_code: i /// without retaining a second ETL parser. pub fn legacy_config_inputs( denials: &[DeniedResource], + current_directory: Option<&str>, ) -> (Vec, HashSet) { let mut events: Vec = Vec::new(); let mut file_event_indices: HashMap = HashMap::new(); @@ -64,7 +65,9 @@ pub fn legacy_config_inputs( for denial in denials { match denial.resource_type { ResourceType::File => { - if !is_local_drive_path(&denial.resource) { + if !is_local_drive_path(&denial.resource) + || is_current_directory_path(&denial.resource, current_directory) + { continue; } let access_mask = match denial.access_type { @@ -96,6 +99,28 @@ pub fn legacy_config_inputs( } } + fn is_current_directory_path(path: &str, current_directory: Option<&str>) -> bool { + let Some(current_directory) = current_directory else { + return false; + }; + let current_directory = current_directory.trim_end_matches('\\'); + let path = path.trim_end_matches('\\'); + if path.eq_ignore_ascii_case(current_directory) { + return true; + } + + let bytes = current_directory.as_bytes(); + let is_drive_root = bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + let path_bytes = path.as_bytes(); + !is_drive_root + && path_bytes.len() > bytes.len() + && path_bytes[..bytes.len()] + .iter() + .zip(bytes) + .all(|(path_byte, cwd_byte)| path_byte.eq_ignore_ascii_case(cwd_byte)) + && path_bytes[bytes.len()] == b'\\' + } + (events, capabilities) } @@ -170,7 +195,7 @@ mod tests { ), ]; - let (events, capabilities) = legacy_config_inputs(&denials); + let (events, capabilities) = legacy_config_inputs(&denials, None); assert_eq!(events.len(), 3); assert_eq!(events[0].access_mask, 0x1); assert_eq!(events[1].access_mask, 0x2); @@ -178,6 +203,27 @@ mod tests { assert_eq!(capabilities, HashSet::from(["internetClient".to_string()])); } + #[test] + fn legacy_inputs_exclude_current_directory_but_not_siblings() { + let denials = vec![ + denial( + r"C:\work\repo\tool.log", + ResourceType::File, + AccessType::Write, + ), + denial( + r"C:\work\repo2\data.txt", + ResourceType::File, + AccessType::Read, + ), + ]; + + let (events, _) = legacy_config_inputs(&denials, Some(r"C:\work\repo")); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].file_path, r"C:\work\repo2\data.txt"); + } + #[test] fn canonical_document_preserves_analysis_results() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/host/plm/src/log.rs b/src/host/plm/src/log.rs index 7347fef9b..35d0d80eb 100644 --- a/src/host/plm/src/log.rs +++ b/src/host/plm/src/log.rs @@ -10,6 +10,7 @@ use anyhow::{Context, Result}; use chrono::Local; +use learning_mode_core::AnalysisResult; use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; @@ -35,6 +36,10 @@ fn prompt_enter(message: &str) -> Result<()> { Ok(()) } +fn can_generate_policy_preview(analysis: &AnalysisResult) -> bool { + !analysis.denied_resources_truncated +} + pub fn run( wprp_path: &Path, verbose: bool, @@ -79,7 +84,18 @@ pub fn run( let analysis = analysis?; write_detection_summary(&analysis); - let (valid_access_events, _) = legacy_config_inputs(&analysis.denials); + if !can_generate_policy_preview(&analysis) { + eprintln!( + "[plm] warning: denial analysis was truncated; skipping blank-config preview \ + because the learned policy would be incomplete" + ); + return Ok(()); + } + let current_directory = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()); + let (valid_access_events, _) = + legacy_config_inputs(&analysis.denials, current_directory.as_deref()); // Synthesize a blank config and run the FS merge to preview what a // policy authored from scratch would receive. Capability and UI @@ -103,3 +119,17 @@ pub fn run( Ok(()) } + +#[cfg(test)] +mod tests { + use super::can_generate_policy_preview; + use learning_mode_core::AnalysisResult; + + #[test] + fn truncated_analysis_cannot_generate_policy_preview() { + assert!(!can_generate_policy_preview(&AnalysisResult { + denials: Vec::new(), + denied_resources_truncated: true, + })); + } +} diff --git a/src/host/plm/src/stop.rs b/src/host/plm/src/stop.rs index cc0b876f5..edcb4d0df 100644 --- a/src/host/plm/src/stop.rs +++ b/src/host/plm/src/stop.rs @@ -287,7 +287,11 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result { }); } - let (valid_access_events, requested_capabilities) = legacy_config_inputs(&analysis.denials); + let current_directory = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()); + let (valid_access_events, requested_capabilities) = + legacy_config_inputs(&analysis.denials, current_directory.as_deref()); write_requested_capabilities_summary(&requested_capabilities, opts.verbose); if valid_access_events.is_empty() && requested_capabilities.is_empty() { From e8a9f150d9dbcbb6c195f161edc6987b6e7b3d14 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 7 Aug 2026 14:13:56 -0700 Subject: [PATCH 03/50] Normalize PLM source line endings Store newly added PLM files with repository-standard LF endings so whitespace validation reports the actual content cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/host/plm/readme.md | 206 ++++---- src/host/plm/src/lib.rs | 52 +- src/host/plm/src/log.rs | 270 +++++----- src/host/plm/src/main.rs | 1046 +++++++++++++++++++------------------- 4 files changed, 787 insertions(+), 787 deletions(-) diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index db3c00bf1..1083e0d21 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -1,103 +1,103 @@ -# PLM — Permissive Learning Mode - -`plm.exe` is the Windows-only legacy WPR trace helper for Learning Mode. It captures both `learningModeLogging` block events and `permissiveLearningMode` allow events, then delegates ETL decoding to the same canonical `learning_mode_windows::EtlDenialAnalyzer` used by `captureDenials`. - -The canonical analyzer decodes filesystem, capability, registry, and UI findings from both provider shapes. The standalone `extract-caps` command remains available only as a low-level ACE diagnostic. - -PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and debugging the parser itself. - -## How it works - -1. **Capture** — `plm start` calls `wpr -start !AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector. -2. **Run** — the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied. -3. **Stop** — `plm stop` calls `wpr -stop ` and analyzes the sealed ETL through `EtlDenialAnalyzer`. -4. **Emit** — canonical findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths. -5. **Merge (temporary compatibility)** — file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it. - -> **Capability merge caveats.** Capabilities are only merged into a `processContainer` block — backends that cannot express AppContainer capabilities (LXC, Windows Sandbox, …) are left untouched and the discovered set is reported on stderr instead. The reserved names `learningModeLogging` and `permissiveLearningMode` are never written back, because `processContainer.capabilities` rejects them. - -## Layout (this PR) - -| File | Role | -|-------------------------|-----------------------------------------------------------------------------------| -| `src/main.rs` | `clap` dispatch for `plm start` / `plm stop` / `plm log` / `plm extract-caps` | -| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` | -| `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS/capability merge | -| `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config | -| `src/analysis.rs` | Canonical ETL analysis, denials JSON emission, and temporary config-generator adapter | -| `src/access_event.rs` | `LearningModeAccessEvent` plain struct | -| `src/extract_caps.rs` | DACL ACE blob decoder; resolves capability SIDs via `DeriveCapabilitySidsFromName` | -| `src/config.rs` | JSON load/mutate; FS + capability merge into containment-backend section | -| `src/coordination.rs` | Cross-process singleton named-mutex + bypass-env-var coordination for `plm log` | -| `src/wpr_path.rs` | Resolves `wpr.exe` to its absolute `%SystemRoot%\System32` path (PATH-spoof-safe) | -| `src/profile_gen.rs` | Inline WPR profile (`EMBEDDED_WPRP`) + run-time writer that drops `plm.wprp` next to `plm.exe` when missing | - -## CLI - -### `plm start` - -Cancels any in-progress WPR session and starts a new permissive-learning-mode trace. - -```powershell -plm.exe start [--wprp ] -``` - -| Flag | Default | Purpose | -|------------|------------------------|---------------------------------------------------------------| -| `--wprp` | `\plm.wprp` | Override the WPR profile path. By default `plm` materializes its embedded profile next to the exe on first use; an existing `plm.wprp` is never overwritten, so operator hand-edits are preserved. | - -### `plm stop` - -Stops the active trace (or accepts a previously captured one). - -```powershell -plm.exe stop [--config-path ] [--log-dir ] [--bin-path ] - [--trace-file | --trace-output ] - [--exit-code ] [--verbose-logging] -``` - -`--trace-output` selects the exact ETL destination passed to `wpr -stop`; it cannot be combined with `--trace-file`, which re-processes an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary. - -`--config-path` temporarily preserves the existing adjusted-config behavior. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it independently. The write is atomic so a downstream enforcing run never observes a truncated policy. - -### `plm extract-caps` - -Decode a raw hex-encoded DACL ACE buffer into a sorted list of AppContainer capability names. Useful for debugging the ACE decoder against ETW payloads dumped by other tools. - -```powershell -plm.exe extract-caps --hex-bytes [--verbose-logging] -``` - -> **An empty result does not mean the blob contained no capabilities.** Only names on the module's built-in known-capability list are recognized, and only when the OS resolves them via `DeriveCapabilitySidsFromName` — names this Windows build rejects are skipped at table-build time, and any SID that is not in the resulting index is ignored. Capabilities are also only collected from *allow* ACEs that grant a non-zero access mask. Use `--verbose-logging` to see per-ACE decisions, including SIDs that resolved to nothing. - -### `plm log` - -Interactive iteration mode: press Enter to start a trace, run the workload, press Enter again to stop. It then synthesizes a blank config, runs the filesystem merge, and prints the resulting config as a "diff against a blank config" preview. - -```powershell -plm.exe log [--wprp ] [--verbose-logging] -``` - -## Building - -PLM is part of the MXC workspace but excluded from `default-members` because it's Windows-only. Build it explicitly: - -```powershell -cd C:\src\mxc\src -cargo build -p plm --target x86_64-pc-windows-msvc -# or for release: -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`); on first use of `plm start` / `plm log`, `profile_gen::ensure_wprp_next_to_exe` writes it to disk next to the binary if no `plm.wprp` is already present. `build.bat` from the repo root builds `plm.exe` and stages it next to `wxc-exec.exe` for the `--audit` integration. - -## Limitations - -- **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent. -- **Deny matching is enforced on literal, lexically-normalized paths only.** `config::normalize_path` strips verbatim/device prefixes, lowercases, collapses separators, and rejects ADS / `.` / `..`, but it is filesystem-free and does **not** resolve directory junctions, symlinks/reparse points, or 8.3 short names. 8.3 short-name aliases of a denied directory are detected lexically and refused promotion (fail-closed), but a junction/symlink alias (e.g. `C:\work\link` → `C:\Secrets`) is a lexically distinct path that will **not** match a deny entry and can therefore be promoted into the persisted `Adjusted_*.json`. Operators must deny the canonical target path; aliasing the target through a reparse point is a known gap. See the deny-matching code in `src/config.rs`. -- The compatibility adjusted-config generator consumes file and capability denials only. UI regeneration moves to the shared opt-in regeneration engine; UI denials are already present in `denials.json`. - -## See also - -- [`docs/process-container/guide.md`](../../../docs/process-container/guide.md) — process-container backend overview -- [README → Debugging → Audit Mode](../../../README.md#audit-mode-permissive-learning-mode) — `wxc-exec --audit` integration +# PLM — Permissive Learning Mode + +`plm.exe` is the Windows-only legacy WPR trace helper for Learning Mode. It captures both `learningModeLogging` block events and `permissiveLearningMode` allow events, then delegates ETL decoding to the same canonical `learning_mode_windows::EtlDenialAnalyzer` used by `captureDenials`. + +The canonical analyzer decodes filesystem, capability, registry, and UI findings from both provider shapes. The standalone `extract-caps` command remains available only as a low-level ACE diagnostic. + +PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mode-permissive-learning-mode); the standalone CLI documented here is for capturing traces, interactive iteration, and debugging the parser itself. + +## How it works + +1. **Capture** — `plm start` calls `wpr -start !AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector. +2. **Run** — the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied. +3. **Stop** — `plm stop` calls `wpr -stop ` and analyzes the sealed ETL through `EtlDenialAnalyzer`. +4. **Emit** — canonical findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths. +5. **Merge (temporary compatibility)** — file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it. + +> **Capability merge caveats.** Capabilities are only merged into a `processContainer` block — backends that cannot express AppContainer capabilities (LXC, Windows Sandbox, …) are left untouched and the discovered set is reported on stderr instead. The reserved names `learningModeLogging` and `permissiveLearningMode` are never written back, because `processContainer.capabilities` rejects them. + +## Layout (this PR) + +| File | Role | +|-------------------------|-----------------------------------------------------------------------------------| +| `src/main.rs` | `clap` dispatch for `plm start` / `plm stop` / `plm log` / `plm extract-caps` | +| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` | +| `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS/capability merge | +| `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config | +| `src/analysis.rs` | Canonical ETL analysis, denials JSON emission, and temporary config-generator adapter | +| `src/access_event.rs` | `LearningModeAccessEvent` plain struct | +| `src/extract_caps.rs` | DACL ACE blob decoder; resolves capability SIDs via `DeriveCapabilitySidsFromName` | +| `src/config.rs` | JSON load/mutate; FS + capability merge into containment-backend section | +| `src/coordination.rs` | Cross-process singleton named-mutex + bypass-env-var coordination for `plm log` | +| `src/wpr_path.rs` | Resolves `wpr.exe` to its absolute `%SystemRoot%\System32` path (PATH-spoof-safe) | +| `src/profile_gen.rs` | Inline WPR profile (`EMBEDDED_WPRP`) + run-time writer that drops `plm.wprp` next to `plm.exe` when missing | + +## CLI + +### `plm start` + +Cancels any in-progress WPR session and starts a new permissive-learning-mode trace. + +```powershell +plm.exe start [--wprp ] +``` + +| Flag | Default | Purpose | +|------------|------------------------|---------------------------------------------------------------| +| `--wprp` | `\plm.wprp` | Override the WPR profile path. By default `plm` materializes its embedded profile next to the exe on first use; an existing `plm.wprp` is never overwritten, so operator hand-edits are preserved. | + +### `plm stop` + +Stops the active trace (or accepts a previously captured one). + +```powershell +plm.exe stop [--config-path ] [--log-dir ] [--bin-path ] + [--trace-file | --trace-output ] + [--exit-code ] [--verbose-logging] +``` + +`--trace-output` selects the exact ETL destination passed to `wpr -stop`; it cannot be combined with `--trace-file`, which re-processes an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary. + +`--config-path` temporarily preserves the existing adjusted-config behavior. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it independently. The write is atomic so a downstream enforcing run never observes a truncated policy. + +### `plm extract-caps` + +Decode a raw hex-encoded DACL ACE buffer into a sorted list of AppContainer capability names. Useful for debugging the ACE decoder against ETW payloads dumped by other tools. + +```powershell +plm.exe extract-caps --hex-bytes [--verbose-logging] +``` + +> **An empty result does not mean the blob contained no capabilities.** Only names on the module's built-in known-capability list are recognized, and only when the OS resolves them via `DeriveCapabilitySidsFromName` — names this Windows build rejects are skipped at table-build time, and any SID that is not in the resulting index is ignored. Capabilities are also only collected from *allow* ACEs that grant a non-zero access mask. Use `--verbose-logging` to see per-ACE decisions, including SIDs that resolved to nothing. + +### `plm log` + +Interactive iteration mode: press Enter to start a trace, run the workload, press Enter again to stop. It then synthesizes a blank config, runs the filesystem merge, and prints the resulting config as a "diff against a blank config" preview. + +```powershell +plm.exe log [--wprp ] [--verbose-logging] +``` + +## Building + +PLM is part of the MXC workspace but excluded from `default-members` because it's Windows-only. Build it explicitly: + +```powershell +cd C:\src\mxc\src +cargo build -p plm --target x86_64-pc-windows-msvc +# or for release: +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`); on first use of `plm start` / `plm log`, `profile_gen::ensure_wprp_next_to_exe` writes it to disk next to the binary if no `plm.wprp` is already present. `build.bat` from the repo root builds `plm.exe` and stages it next to `wxc-exec.exe` for the `--audit` integration. + +## Limitations + +- **Windows-only.** Uses `wpr.exe` and Job-Object UI-limit semantics that have no portable equivalent. +- **Deny matching is enforced on literal, lexically-normalized paths only.** `config::normalize_path` strips verbatim/device prefixes, lowercases, collapses separators, and rejects ADS / `.` / `..`, but it is filesystem-free and does **not** resolve directory junctions, symlinks/reparse points, or 8.3 short names. 8.3 short-name aliases of a denied directory are detected lexically and refused promotion (fail-closed), but a junction/symlink alias (e.g. `C:\work\link` → `C:\Secrets`) is a lexically distinct path that will **not** match a deny entry and can therefore be promoted into the persisted `Adjusted_*.json`. Operators must deny the canonical target path; aliasing the target through a reparse point is a known gap. See the deny-matching code in `src/config.rs`. +- The compatibility adjusted-config generator consumes file and capability denials only. UI regeneration moves to the shared opt-in regeneration engine; UI denials are already present in `denials.json`. + +## See also + +- [`docs/process-container/guide.md`](../../../docs/process-container/guide.md) — process-container backend overview +- [README → Debugging → Audit Mode](../../../README.md#audit-mode-permissive-learning-mode) — `wxc-exec --audit` integration diff --git a/src/host/plm/src/lib.rs b/src/host/plm/src/lib.rs index ffd533a03..1e7e19809 100644 --- a/src/host/plm/src/lib.rs +++ b/src/host/plm/src/lib.rs @@ -1,26 +1,26 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Library surface for the permissive learning mode (PLM) crate. -//! Pure-data modules compile cross-platform; Windows-only items are -//! gated per-module. The `plm` binary in `main.rs` is Windows-only. - -pub mod access_event; -#[cfg(target_os = "windows")] -pub mod analysis; -pub mod config; -pub mod coordination; -pub mod extract_caps; -pub mod profile_gen; - -#[cfg(target_os = "windows")] -pub mod log; - -#[cfg(target_os = "windows")] -pub mod start; - -#[cfg(target_os = "windows")] -pub mod stop; - -#[cfg(target_os = "windows")] -pub mod wpr_path; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Library surface for the permissive learning mode (PLM) crate. +//! Pure-data modules compile cross-platform; Windows-only items are +//! gated per-module. The `plm` binary in `main.rs` is Windows-only. + +pub mod access_event; +#[cfg(target_os = "windows")] +pub mod analysis; +pub mod config; +pub mod coordination; +pub mod extract_caps; +pub mod profile_gen; + +#[cfg(target_os = "windows")] +pub mod log; + +#[cfg(target_os = "windows")] +pub mod start; + +#[cfg(target_os = "windows")] +pub mod stop; + +#[cfg(target_os = "windows")] +pub mod wpr_path; diff --git a/src/host/plm/src/log.rs b/src/host/plm/src/log.rs index 35d0d80eb..121b284a2 100644 --- a/src/host/plm/src/log.rs +++ b/src/host/plm/src/log.rs @@ -1,135 +1,135 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Interactive "logging" mode. -//! -//! 1. Prompts the user to press Enter to start logging. -//! 2. Starts a WPR trace (same `AccessFailureProfile` used by `start`). -//! 3. Prompts the user to press Enter to stop logging. -//! 4. Stops the trace into a temp .etl and reports where it landed. - -use anyhow::{Context, Result}; -use chrono::Local; -use learning_mode_core::AnalysisResult; -use serde_json::{json, Value}; -use std::io::{self, BufRead, Write}; -use std::path::{Path, PathBuf}; - -use crate::analysis::{analyze_trace, legacy_config_inputs, write_detection_summary}; -use crate::config::{ - deny_file_set, initialize_filesystem, update_from_access_events, write_added_paths_summary, -}; -use crate::coordination::PLM_LOG_START_IN_FLIGHT; -use crate::start; -use crate::stop::{stop_plm_trace_with, WprExeStopper}; -use std::sync::atomic::Ordering; - -fn prompt_enter(message: &str) -> Result<()> { - print!("{message}"); - io::stdout().flush().ok(); - let stdin = io::stdin(); - let mut line = String::new(); - stdin - .lock() - .read_line(&mut line) - .context("failed to read from stdin")?; - Ok(()) -} - -fn can_generate_policy_preview(analysis: &AnalysisResult) -> bool { - !analysis.denied_resources_truncated -} - -pub fn run( - wprp_path: &Path, - verbose: bool, - on_trace_started: impl FnOnce(), - on_trace_stopped: impl FnOnce(), -) -> Result<()> { - prompt_enter("Press Enter to start logging...")?; - // Bracket the `wpr -start` spawn so the console-control handler - // in `plm/src/main.rs` waits for it to drain before deciding - // whether to issue `wpr -cancel`. Closes the same race the - // wxc-exec side closes with `AUDIT_START_IN_FLIGHT`. - PLM_LOG_START_IN_FLIGHT.store(true, Ordering::SeqCst); - let start_result = start::start_plm_trace(wprp_path); - PLM_LOG_START_IN_FLIGHT.store(false, Ordering::SeqCst); - start_result?; - // `wpr -start` has engaged the kernel session. Only NOW mark the - // trace active so a stdin-EOF / spawn-fail before this point can't - // trip the Ctrl+C handler into `wpr -cancel`ing an unrelated host - // WPR session. - on_trace_started(); - println!("Logging started."); - - prompt_enter("Press Enter to stop logging...")?; - - // Per-run trace file in temp; PID + sub-second component prevents - // parallel `plm log` invocations from colliding on the same .etl. - let stamp = Local::now().format("%Y-%m-%d_%H%M%S%.3f").to_string(); - let trace_file: PathBuf = std::env::temp_dir().join(format!("plm_log_{stamp}.etl")); - stop_plm_trace_with(&mut WprExeStopper, &trace_file)?; - // Kernel session is torn down; safe to clear the active flag so - // any subsequent Ctrl+C doesn't issue a stale `wpr -cancel`. - on_trace_stopped(); - - if verbose { - println!("Beginning event parsing, this may take several minutes"); - } - - let analysis = analyze_trace(&trace_file); - - // Clean up the temp .etl regardless of analysis outcome. - let _ = std::fs::remove_file(&trace_file); - - let analysis = analysis?; - write_detection_summary(&analysis); - if !can_generate_policy_preview(&analysis) { - eprintln!( - "[plm] warning: denial analysis was truncated; skipping blank-config preview \ - because the learned policy would be incomplete" - ); - return Ok(()); - } - let current_directory = std::env::current_dir() - .ok() - .map(|path| path.to_string_lossy().into_owned()); - let (valid_access_events, _) = - legacy_config_inputs(&analysis.denials, current_directory.as_deref()); - - // Synthesize a blank config and run the FS merge to preview what a - // policy authored from scratch would receive. Capability and UI - // merging arrive in later PRs. - let mut blank: Value = json!({}); - initialize_filesystem(&mut blank)?; - let deny = deny_file_set(&blank); - - // For a blank config there is no app binary to skip -- pass a path - // that will never match a real event's file path. - let bin_path = String::from("\\\\plm-blank-config-bin-sentinel"); - - let added = - update_from_access_events(&mut blank, &bin_path, &valid_access_events, &deny, verbose)?; - - write_added_paths_summary(&added, verbose); - - println!(); - println!("Blank config after merge:"); - println!("{}", serde_json::to_string_pretty(&blank)?); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::can_generate_policy_preview; - use learning_mode_core::AnalysisResult; - - #[test] - fn truncated_analysis_cannot_generate_policy_preview() { - assert!(!can_generate_policy_preview(&AnalysisResult { - denials: Vec::new(), - denied_resources_truncated: true, - })); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Interactive "logging" mode. +//! +//! 1. Prompts the user to press Enter to start logging. +//! 2. Starts a WPR trace (same `AccessFailureProfile` used by `start`). +//! 3. Prompts the user to press Enter to stop logging. +//! 4. Stops the trace into a temp .etl and reports where it landed. + +use anyhow::{Context, Result}; +use chrono::Local; +use learning_mode_core::AnalysisResult; +use serde_json::{json, Value}; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use crate::analysis::{analyze_trace, legacy_config_inputs, write_detection_summary}; +use crate::config::{ + deny_file_set, initialize_filesystem, update_from_access_events, write_added_paths_summary, +}; +use crate::coordination::PLM_LOG_START_IN_FLIGHT; +use crate::start; +use crate::stop::{stop_plm_trace_with, WprExeStopper}; +use std::sync::atomic::Ordering; + +fn prompt_enter(message: &str) -> Result<()> { + print!("{message}"); + io::stdout().flush().ok(); + let stdin = io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .context("failed to read from stdin")?; + Ok(()) +} + +fn can_generate_policy_preview(analysis: &AnalysisResult) -> bool { + !analysis.denied_resources_truncated +} + +pub fn run( + wprp_path: &Path, + verbose: bool, + on_trace_started: impl FnOnce(), + on_trace_stopped: impl FnOnce(), +) -> Result<()> { + prompt_enter("Press Enter to start logging...")?; + // Bracket the `wpr -start` spawn so the console-control handler + // in `plm/src/main.rs` waits for it to drain before deciding + // whether to issue `wpr -cancel`. Closes the same race the + // wxc-exec side closes with `AUDIT_START_IN_FLIGHT`. + PLM_LOG_START_IN_FLIGHT.store(true, Ordering::SeqCst); + let start_result = start::start_plm_trace(wprp_path); + PLM_LOG_START_IN_FLIGHT.store(false, Ordering::SeqCst); + start_result?; + // `wpr -start` has engaged the kernel session. Only NOW mark the + // trace active so a stdin-EOF / spawn-fail before this point can't + // trip the Ctrl+C handler into `wpr -cancel`ing an unrelated host + // WPR session. + on_trace_started(); + println!("Logging started."); + + prompt_enter("Press Enter to stop logging...")?; + + // Per-run trace file in temp; PID + sub-second component prevents + // parallel `plm log` invocations from colliding on the same .etl. + let stamp = Local::now().format("%Y-%m-%d_%H%M%S%.3f").to_string(); + let trace_file: PathBuf = std::env::temp_dir().join(format!("plm_log_{stamp}.etl")); + stop_plm_trace_with(&mut WprExeStopper, &trace_file)?; + // Kernel session is torn down; safe to clear the active flag so + // any subsequent Ctrl+C doesn't issue a stale `wpr -cancel`. + on_trace_stopped(); + + if verbose { + println!("Beginning event parsing, this may take several minutes"); + } + + let analysis = analyze_trace(&trace_file); + + // Clean up the temp .etl regardless of analysis outcome. + let _ = std::fs::remove_file(&trace_file); + + let analysis = analysis?; + write_detection_summary(&analysis); + if !can_generate_policy_preview(&analysis) { + eprintln!( + "[plm] warning: denial analysis was truncated; skipping blank-config preview \ + because the learned policy would be incomplete" + ); + return Ok(()); + } + let current_directory = std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()); + let (valid_access_events, _) = + legacy_config_inputs(&analysis.denials, current_directory.as_deref()); + + // Synthesize a blank config and run the FS merge to preview what a + // policy authored from scratch would receive. Capability and UI + // merging arrive in later PRs. + let mut blank: Value = json!({}); + initialize_filesystem(&mut blank)?; + let deny = deny_file_set(&blank); + + // For a blank config there is no app binary to skip -- pass a path + // that will never match a real event's file path. + let bin_path = String::from("\\\\plm-blank-config-bin-sentinel"); + + let added = + update_from_access_events(&mut blank, &bin_path, &valid_access_events, &deny, verbose)?; + + write_added_paths_summary(&added, verbose); + + println!(); + println!("Blank config after merge:"); + println!("{}", serde_json::to_string_pretty(&blank)?); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::can_generate_policy_preview; + use learning_mode_core::AnalysisResult; + + #[test] + fn truncated_analysis_cannot_generate_policy_preview() { + assert!(!can_generate_policy_preview(&AnalysisResult { + denials: Vec::new(), + denied_resources_truncated: true, + })); + } +} diff --git a/src/host/plm/src/main.rs b/src/host/plm/src/main.rs index 5d7f8b1a1..71db66a2c 100644 --- a/src/host/plm/src/main.rs +++ b/src/host/plm/src/main.rs @@ -1,523 +1,523 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Rust port of the permissive learning mode (PLM) PowerShell scripts. -//! -//! Subcommands: -//! - `start`: cancel any active WPR trace and start a new one using -//! `plm.wprp!AccessFailureProfile`. -//! - `stop`: stop the trace and process captured events. -//! - `log`: interactive — Enter to start, Enter to stop. -//! - `extract-caps`: standalone ACE decoder. -//! -//! The functional binary wraps WPR / ETW / EventLog APIs that have no -//! cross-platform equivalent and is therefore Windows-only. On -//! Linux/macOS we still compile a stub binary so the crate sits inside -//! the workspace `default-members` list (one members list to maintain, -//! cross-platform CI catches drift); invoking it prints a message and -//! exits non-zero. - -#[cfg(not(target_os = "windows"))] -fn main() { - eprintln!("plm is Windows-only; this stub binary does nothing on non-Windows targets."); - std::process::exit(1); -} - -#[cfg(target_os = "windows")] -use anyhow::{Context, Result}; -#[cfg(target_os = "windows")] -use clap::{Parser, Subcommand}; -#[cfg(target_os = "windows")] -use std::path::PathBuf; -#[cfg(target_os = "windows")] -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; -#[cfg(target_os = "windows")] -use std::time::Duration; - -#[cfg(target_os = "windows")] -use plm::coordination::{singleton_bypass_requested, wait_until_cleared, PLM_LOG_START_IN_FLIGHT}; -#[cfg(target_os = "windows")] -use plm::{extract_caps, log, profile_gen, start, stop}; - -/// Raw `HANDLE` value of the named-mutex singleton acquired by -/// `acquire_singleton_if_needed` (zero when unheld). Stashed in a -/// static so the console-control handler can release the host-wide -/// `Global\Mxc_Plm_Audit` guard before `ExitProcess` runs and skips -/// Rust destructors, preventing the retry-on-conflict path in -/// `start_plm_trace` from `wpr -cancel`ing a peer PLM trace. -#[cfg(target_os = "windows")] -static PLM_SINGLETON_HANDLE: AtomicIsize = AtomicIsize::new(0); - -/// Backing storage for `AcquiredSingleton::mark_trace_active` / -/// `clear_trace_active` / `cancel_active_trace`. -/// -/// Kept as a process-wide `static` (not an owned field of -/// `AcquiredSingleton`) for one narrow reason: the Windows console- -/// control handler `plm_ctrl_handler` is an OS-owned `extern "system"` -/// callback with no `self` / captured environment. It can only reach -/// state via process globals. Access from the `main` thread, however, -/// is gated behind `&AcquiredSingleton` methods so it is a -/// compile-time invariant that the trace-active flag can only be -/// mutated while we hold the host-wide singleton mutex — you can't -/// call `mark_trace_active()` in a free function without first -/// producing an `AcquiredSingleton`. -#[cfg(target_os = "windows")] -static PLM_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); - -/// Release the named-mutex singleton if held. Idempotent. -#[cfg(target_os = "windows")] -fn release_plm_singleton() { - plm::coordination::singleton::release(&PLM_SINGLETON_HANDLE); -} - -/// Cancel any active PLM trace from a context that can't produce an -/// `&AcquiredSingleton` — currently just the ctrl handler, which -/// runs in an OS-owned callback with no captured environment. All -/// non-signal-context callers should use -/// `AcquiredSingleton::cancel_active_trace(&self)` instead so the -/// call site proves the singleton is held. -#[cfg(target_os = "windows")] -fn cancel_active_plm_trace_from_signal() { - if PLM_TRACE_ACTIVE.swap(false, Ordering::SeqCst) { - // Use the kernel-published System32 path. - let _ = plm::wpr_path::wpr_command() - .arg("-cancel") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } -} - -/// RAII wrapper for the host-wide `Global\Mxc_Plm_Audit` singleton. -/// Ownership of the singleton is the precondition for touching the -/// trace-active flag — the methods below take `&self` so a live -/// `AcquiredSingleton` must exist at every call site. -#[cfg(target_os = "windows")] -struct AcquiredSingleton; - -#[cfg(target_os = "windows")] -impl AcquiredSingleton { - /// Mark the kernel ETW session as live; called immediately after - /// `start::start_plm_trace` succeeds. - fn mark_trace_active(&self) { - PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst); - } - - /// Clear the trace-active flag; called after `wpr -stop` drains - /// the kernel session so a subsequent Ctrl+C doesn't issue a - /// stale `wpr -cancel`. - fn clear_trace_active(&self) { - PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst); - } - - /// Issue `wpr -cancel` iff a trace was marked active by this - /// process. Idempotent. Non-signal-context callers use this - /// method; the ctrl handler uses `cancel_active_plm_trace_from_signal`. - fn cancel_active_trace(&self) { - cancel_active_plm_trace_from_signal(); - } -} - -#[cfg(target_os = "windows")] -impl Drop for AcquiredSingleton { - fn drop(&mut self) { - // Cancel any leftover trace before releasing the singleton so - // a caller that returns an error mid-flow can't leak the - // kernel session past our exit. - self.cancel_active_trace(); - release_plm_singleton(); - } -} - -#[cfg(target_os = "windows")] -fn acquire_singleton_if_needed() -> Result> { - if singleton_bypass_requested() { - // Outer process holds the mutex for the whole audit window; - // re-acquiring here would deadlock. - return Ok(None); - } - use plm::coordination::singleton::{try_acquire, AcquireError}; - match try_acquire(&PLM_SINGLETON_HANDLE) { - Ok(()) => Ok(Some(AcquiredSingleton)), - Err(AcquireError::AlreadyHeld) => anyhow::bail!( - "another PLM trace is already in progress (Global\\Mxc_Plm_Audit held); \ - refusing to start a second concurrent trace — only one NT Kernel Logger \ - session can exist per host" - ), - Err(AcquireError::CreateFailed(e)) => { - Err(e).context("CreateMutexW failed for Global\\Mxc_Plm_Audit") - } - } -} - -/// Windows console-control handler. Fires on Ctrl+C, Ctrl+Break, -/// console close, logoff, and shutdown. Tears down any in-flight WPR -/// session and releases the singleton mutex before the default handler -/// calls `ExitProcess` (which skips Rust destructors). -/// -/// We poll `PLM_LOG_START_IN_FLIGHT` via `wait_until_cleared` instead -/// of a proper wait-object (Event / condvar) for two reasons: -/// 1. `wpr -start`'s underlying kernel session engagement isn't -/// signalled by any OS-published handle we can wait on; the only -/// transition we can observe is the child `wpr.exe` process -/// returning. Wrapping a Rust `Event` around that in the ctrl -/// handler would still require polling / a spawn-time helper -/// thread purely to `SetEvent`. -/// 2. The polling interval (50ms) is bounded above by -/// `CTRL_HANDLER_DRAIN_TIMEOUT` (2s) which is well under -/// Windows's ~5s ctrl-handler kill budget, so at most ~40 polls -/// fire — negligible CPU, zero cost on the happy path (the flag -/// is normally already clear when the handler runs). -#[cfg(target_os = "windows")] -unsafe extern "system" fn plm_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { - // if `plm log`'s `wpr -start` is - // still in flight when Ctrl+C arrives, briefly wait for it to - // settle before deciding whether to issue `wpr -cancel`. Without - // this wait, a cancel that races a not-yet-engaged session is a - // no-op and the kernel session leaks past `plm.exe` exit. - // - // timeout sourced from the - // shared `plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT` so - // `plm.exe` and `wxc-exec`'s `dacl_ctrl_handler` cannot drift - // apart. The const docs explain the ~5s OS kill budget rationale. - // Polls via the shared `wait_until_cleared` helper so the same - // loop is tested in one place — see `plm::coordination::tests`. - let _ = wait_until_cleared( - &PLM_LOG_START_IN_FLIGHT, - plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT, - Duration::from_millis(50), - ); - cancel_active_plm_trace_from_signal(); - release_plm_singleton(); - // Return FALSE so the default handler still runs and terminates - // the process. Matches `wxc-exec`'s dacl_ctrl_handler pattern. - windows::core::BOOL(0) -} - -#[cfg(target_os = "windows")] -fn install_ctrl_handler() { - use windows::Win32::System::Console::SetConsoleCtrlHandler; - // SAFETY: handler has the correct ABI; Add=TRUE merely appends to - // the OS handler chain. - let _ = unsafe { SetConsoleCtrlHandler(Some(plm_ctrl_handler), true) }; -} - -#[derive(Parser, Debug)] -#[command( - name = "plm", - about = "Rust port of the permissive learning mode PowerShell scripts.", - version -)] -#[cfg(target_os = "windows")] -struct Cli { - /// Internal handshake flag used by `wxc-exec --audit` to hand off - /// a directory the elevated `plm.exe` writes its stdout/stderr - /// into. See `redirect_stdio_from_argv`. Hidden from `--help`; - /// not part of the user-facing CLI. Declared here so clap accepts - /// (and ignores) the flag during subcommand parsing. - #[arg(long = "wxc-capture-dir", hide = true)] - _wxc_capture_dir: Option, - - /// Internal handshake flag used by `wxc-exec --audit` to tell us - /// it already holds the `Global\Mxc_Plm_Audit` singleton so we - /// skip acquisition and avoid a deadlock. Companion of - /// `--wxc-capture-dir`; both migrated off the previous env-var - /// mechanism because `ShellExecuteExW` + `runas` does not - /// propagate environment across the elevation boundary. - #[arg(long = "wxc-singleton-held-by-parent", hide = true)] - wxc_singleton_held_by_parent: bool, - - #[command(subcommand)] - cmd: Cmd, -} - -#[derive(Subcommand, Debug)] -#[cfg(target_os = "windows")] -enum Cmd { - /// Start a new WPR trace using plm.wprp!AccessFailureProfile. - Start { - /// Override path to plm.wprp. Defaults to \plm.wprp. - #[arg(long)] - wprp: Option, - }, - /// Stop the trace and write `trace.etl` into a log directory. - Stop { - /// Directory for trace.etl, copied input config, and Adjusted_*.json. - #[arg(long)] - log_dir: Option, - /// Path treated as the application binary's location. Defaults - /// to the directory containing the plm executable. Used as the - /// self-access filter root in the adjusted config. - #[arg(long)] - bin_path: Option, - /// Path to the MXC container config (JSON) to update. - #[arg(long)] - config_path: Option, - /// Re-process a previously captured .etl instead of stopping a - /// live WPR session. When set, `wpr -stop` is skipped and the - /// supplied file is parsed as-is. - #[arg(long)] - trace_file: Option, - /// Exact destination for the ETL produced by `wpr -stop`. - #[arg(long, conflicts_with = "trace_file")] - trace_output: Option, - /// Workload exit code to record in the canonical denials JSON. - #[arg(long, default_value_t = 0)] - exit_code: i32, - /// Emit per-event/per-ACE diagnostic output. - #[arg(long)] - verbose_logging: bool, - }, - /// Run extract_caps on a hex-encoded ACE blob and print matched - /// capability names. Mirrors the standalone usage of extract_caps.ps1. - ExtractCaps { - /// Hex-encoded ACE buffer (whitespace allowed, even length). - #[arg(long)] - hex_bytes: String, - /// Emit per-ACE diagnostic output. - #[arg(long)] - verbose_logging: bool, - }, - /// Interactive: press Enter to start logging, press Enter again to stop. - Log { - /// Override path to plm.wprp. Defaults to \plm.wprp. - #[arg(long)] - wprp: Option, - /// Emit per-event/per-ACE diagnostic output. - #[arg(long)] - verbose_logging: bool, - }, -} - -#[cfg(target_os = "windows")] -fn exe_dir() -> Result { - let exe = std::env::current_exe().context("failed to resolve current exe path")?; - Ok(exe - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from("."))) -} - -/// Scan argv for `--wxc-capture-dir ` and, if present, redirect -/// this process's stdout/stderr to `/stdout.log` and -/// `/stderr.log`. Called before `Cli::parse()` so any error the -/// runtime prints (including our own arg-parse errors) reaches the -/// capture files. -/// -/// Used when `wxc-exec --audit` launches us elevated via -/// `ShellExecuteExW` + `runas`. That elevation path can inherit -/// neither our stdio handles nor our environment block (the AppInfo -/// service creates the child with a fresh env for the elevated -/// token), so environment-variable–based handoff of the capture -/// paths does not work — we must pass them on the command line. The -/// flag is also declared as a hidden `#[arg(long, hide = true)]` on -/// `Cli` so clap accepts (and ignores) it during subcommand parsing. -/// -/// On file-open failure we silently fall through — the operator -/// loses that stream's diagnostics but the rest of plm still runs. -#[cfg(target_os = "windows")] -fn redirect_stdio_from_argv() { - use std::fs::OpenOptions; - use std::os::windows::io::AsRawHandle; - use std::path::Path; - use windows::Win32::Foundation::HANDLE; - use windows::Win32::System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE}; - - let argv: Vec = std::env::args_os().collect(); - let mut dir: Option = None; - let mut i = 1; - while i < argv.len() { - if argv[i] == "--wxc-capture-dir" && i + 1 < argv.len() { - dir = Some(std::path::PathBuf::from(&argv[i + 1])); - break; - } - i += 1; - } - let Some(dir) = dir else { return }; - - fn redirect_one(path: &Path, which: windows::Win32::System::Console::STD_HANDLE) { - // `create_new(true)` maps to `CREATE_NEW` on Windows, which - // fails with `ERROR_FILE_EXISTS` if anything (regular file, - // directory, symlink, junction target — any reparse point) - // already occupies the path. Combined with the caller-side - // random-suffix temp dir (see `plm_launch::run_plm_elevated`), - // this closes the elevation-boundary symlink attack: a same- - // user medium-IL attacker cannot pre-plant `stdout.log` / - // `stderr.log` as a symlink pointing at an admin-only file - // and have this elevated (admin-token) process silently - // append attacker-controllable bytes to that target. - // - // If create_new fails (attacker successfully raced us, or - // some other fs error) we silently give up — the operator - // loses that stream's diagnostics but no privilege boundary - // is crossed. - let Ok(f) = OpenOptions::new().create_new(true).append(true).open(path) else { - return; - }; - let handle = HANDLE(f.as_raw_handle()); - // Leak the file so the handle stays alive for the process's - // lifetime. `SetStdHandle` records the raw handle; if the - // File drops, the handle closes and subsequent writes fail. - std::mem::forget(f); - // SAFETY: `which` is a valid STD_* constant; `handle` was - // just returned from OpenOptions::open and remains valid - // because we forgot the File. - let _ = unsafe { SetStdHandle(which, handle) }; - } - - redirect_one(&dir.join("stdout.log"), STD_OUTPUT_HANDLE); - redirect_one(&dir.join("stderr.log"), STD_ERROR_HANDLE); -} - -#[cfg(target_os = "windows")] -fn main() -> Result<()> { - // If wxc-exec spawned us elevated via ShellExecuteExW+runas, it - // cannot inherit our stdio pipes across the elevation boundary - // AND the AppInfo service that brokers the elevation does not - // propagate our environment block to the elevated child. The - // capture-file directory is therefore passed as a hidden CLI - // argument (`--wxc-capture-dir`) rather than via env; we redirect - // stdout/stderr to files inside it before touching clap so any - // arg-parse errors also reach the operator. Silent no-op when - // the flag is absent (direct user invocation from an elevated - // shell). - redirect_stdio_from_argv(); - - let cli = Cli::parse(); - // Honour the parent-holds-singleton signal wxc-exec passed as a - // CLI flag. Set BEFORE any acquire_singleton_if_needed call so - // the bypass fires. We keep the env-var path in - // singleton_bypass_requested as a compatibility fallback for - // direct callers that inherit env normally (see coordination.rs). - if cli.wxc_singleton_held_by_parent { - plm::coordination::set_singleton_bypass_override(true); - } - let exe = exe_dir()?; - - // Confirm the resolved wpr.exe exists at `%SystemDirectory%` - // before we go further. We rely on `GetSystemDirectoryW` (not - // env-spoofable) plus the OS TrustedInstaller ACL on that - // directory as the trust boundary; see `wpr_path` module docs for - // why we do not run WinVerifyTrust on the resolved binary. - plm::wpr_path::verify_wpr_present() - .map_err(|e| anyhow::anyhow!("wpr.exe check failed: {e}"))?; - - // Install the Ctrl+C handler unconditionally so signals during any - // subcommand (in particular interactive `log`) tear down the WPR - // session and release the singleton before ExitProcess fires. - install_ctrl_handler(); - - match cli.cmd { - Cmd::Start { wprp } => { - let _singleton = acquire_singleton_if_needed()?; - // Default: materialize the embedded `plm.wprp` next to the - // exe if one isn't already there. - let wprp_path = match wprp { - Some(p) => p, - None => profile_gen::ensure_wprp_next_to_exe(&exe) - .context("failed to stage plm.wprp next to plm.exe")?, - }; - start::start_plm_trace(&wprp_path)?; - // `plm start` exits immediately and leaves the kernel ETW - // session running until a later `plm stop` / `wpr -stop`. - // We deliberately do NOT mark PLM_TRACE_ACTIVE here: this - // process is about to exit and can't be the one to cancel - // the session it just kicked off. The matching `plm stop` - // (or wxc-exec's `cancel_active_audit_trace` cleanup path - // on Ctrl+C) is what owns teardown. - Ok(()) - } - Cmd::Stop { - log_dir, - bin_path, - config_path, - trace_file, - trace_output, - exit_code, - verbose_logging, - } => { - let _singleton = acquire_singleton_if_needed()?; - let result = stop::run( - stop::StopOptions { - log_dir, - bin_path, - config_path, - trace_file, - trace_output, - exit_code, - verbose: verbose_logging, - }, - &exe, - )?; - println!("{}", serde_json::to_string(&result)?); - Ok(()) - } - Cmd::ExtractCaps { - hex_bytes, - verbose_logging, - } => { - let caps = extract_caps::extract_caps(&hex_bytes, verbose_logging)?; - for c in extract_caps::sorted_capability_names(&caps) { - println!("{c}"); - } - Ok(()) - } - Cmd::Log { - wprp, - verbose_logging, - } => { - let singleton = acquire_singleton_if_needed()?; - // see `Cmd::Start` above — stage the embedded profile if - // missing. - let wprp_path = match wprp { - Some(p) => p, - None => profile_gen::ensure_wprp_next_to_exe(&exe) - .context("failed to stage plm.wprp next to plm.exe")?, - }; - // The interactive `log` flow is the only standalone path - // that holds a live trace inside a single process. We hand - // `log::run` closures that call - // `AcquiredSingleton::mark_trace_active` / - // `clear_trace_active` on the borrowed singleton — the - // `&AcquiredSingleton` methods encode at compile time that - // trace-active can only be set while we hold the host-wide - // singleton mutex. `mark_trace_active` flips the flag only - // AFTER `wpr -start` has actually engaged the kernel - // session, so a stdin-EOF or spawn-fail before that point - // cannot trip the Ctrl+C handler into `wpr -cancel`ing an - // unrelated host WPR session. - let result = if let Some(s) = singleton.as_ref() { - log::run( - &wprp_path, - verbose_logging, - || s.mark_trace_active(), - || s.clear_trace_active(), - ) - } else { - // Singleton bypass path (wxc-exec --audit already - // holds the mutex). No `AcquiredSingleton` exists in - // this process, so we can't gate the flag on it — - // fall back to the free-function path that the ctrl - // handler also uses. The outer process owns cleanup. - log::run( - &wprp_path, - verbose_logging, - || PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst), - || PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst), - ) - }; - // If `log::run` returned Err AND the trace had been marked - // active (start succeeded but stop or later step failed), - // the flag is still set — issue `wpr -cancel` so the NT - // Kernel Logger session doesn't leak until reboot. - if result.is_err() { - if let Some(s) = singleton.as_ref() { - s.cancel_active_trace(); - } else { - cancel_active_plm_trace_from_signal(); - } - } - result - } - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Rust port of the permissive learning mode (PLM) PowerShell scripts. +//! +//! Subcommands: +//! - `start`: cancel any active WPR trace and start a new one using +//! `plm.wprp!AccessFailureProfile`. +//! - `stop`: stop the trace and process captured events. +//! - `log`: interactive — Enter to start, Enter to stop. +//! - `extract-caps`: standalone ACE decoder. +//! +//! The functional binary wraps WPR / ETW / EventLog APIs that have no +//! cross-platform equivalent and is therefore Windows-only. On +//! Linux/macOS we still compile a stub binary so the crate sits inside +//! the workspace `default-members` list (one members list to maintain, +//! cross-platform CI catches drift); invoking it prints a message and +//! exits non-zero. + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("plm is Windows-only; this stub binary does nothing on non-Windows targets."); + std::process::exit(1); +} + +#[cfg(target_os = "windows")] +use anyhow::{Context, Result}; +#[cfg(target_os = "windows")] +use clap::{Parser, Subcommand}; +#[cfg(target_os = "windows")] +use std::path::PathBuf; +#[cfg(target_os = "windows")] +use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +#[cfg(target_os = "windows")] +use std::time::Duration; + +#[cfg(target_os = "windows")] +use plm::coordination::{singleton_bypass_requested, wait_until_cleared, PLM_LOG_START_IN_FLIGHT}; +#[cfg(target_os = "windows")] +use plm::{extract_caps, log, profile_gen, start, stop}; + +/// Raw `HANDLE` value of the named-mutex singleton acquired by +/// `acquire_singleton_if_needed` (zero when unheld). Stashed in a +/// static so the console-control handler can release the host-wide +/// `Global\Mxc_Plm_Audit` guard before `ExitProcess` runs and skips +/// Rust destructors, preventing the retry-on-conflict path in +/// `start_plm_trace` from `wpr -cancel`ing a peer PLM trace. +#[cfg(target_os = "windows")] +static PLM_SINGLETON_HANDLE: AtomicIsize = AtomicIsize::new(0); + +/// Backing storage for `AcquiredSingleton::mark_trace_active` / +/// `clear_trace_active` / `cancel_active_trace`. +/// +/// Kept as a process-wide `static` (not an owned field of +/// `AcquiredSingleton`) for one narrow reason: the Windows console- +/// control handler `plm_ctrl_handler` is an OS-owned `extern "system"` +/// callback with no `self` / captured environment. It can only reach +/// state via process globals. Access from the `main` thread, however, +/// is gated behind `&AcquiredSingleton` methods so it is a +/// compile-time invariant that the trace-active flag can only be +/// mutated while we hold the host-wide singleton mutex — you can't +/// call `mark_trace_active()` in a free function without first +/// producing an `AcquiredSingleton`. +#[cfg(target_os = "windows")] +static PLM_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); + +/// Release the named-mutex singleton if held. Idempotent. +#[cfg(target_os = "windows")] +fn release_plm_singleton() { + plm::coordination::singleton::release(&PLM_SINGLETON_HANDLE); +} + +/// Cancel any active PLM trace from a context that can't produce an +/// `&AcquiredSingleton` — currently just the ctrl handler, which +/// runs in an OS-owned callback with no captured environment. All +/// non-signal-context callers should use +/// `AcquiredSingleton::cancel_active_trace(&self)` instead so the +/// call site proves the singleton is held. +#[cfg(target_os = "windows")] +fn cancel_active_plm_trace_from_signal() { + if PLM_TRACE_ACTIVE.swap(false, Ordering::SeqCst) { + // Use the kernel-published System32 path. + let _ = plm::wpr_path::wpr_command() + .arg("-cancel") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + +/// RAII wrapper for the host-wide `Global\Mxc_Plm_Audit` singleton. +/// Ownership of the singleton is the precondition for touching the +/// trace-active flag — the methods below take `&self` so a live +/// `AcquiredSingleton` must exist at every call site. +#[cfg(target_os = "windows")] +struct AcquiredSingleton; + +#[cfg(target_os = "windows")] +impl AcquiredSingleton { + /// Mark the kernel ETW session as live; called immediately after + /// `start::start_plm_trace` succeeds. + fn mark_trace_active(&self) { + PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst); + } + + /// Clear the trace-active flag; called after `wpr -stop` drains + /// the kernel session so a subsequent Ctrl+C doesn't issue a + /// stale `wpr -cancel`. + fn clear_trace_active(&self) { + PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst); + } + + /// Issue `wpr -cancel` iff a trace was marked active by this + /// process. Idempotent. Non-signal-context callers use this + /// method; the ctrl handler uses `cancel_active_plm_trace_from_signal`. + fn cancel_active_trace(&self) { + cancel_active_plm_trace_from_signal(); + } +} + +#[cfg(target_os = "windows")] +impl Drop for AcquiredSingleton { + fn drop(&mut self) { + // Cancel any leftover trace before releasing the singleton so + // a caller that returns an error mid-flow can't leak the + // kernel session past our exit. + self.cancel_active_trace(); + release_plm_singleton(); + } +} + +#[cfg(target_os = "windows")] +fn acquire_singleton_if_needed() -> Result> { + if singleton_bypass_requested() { + // Outer process holds the mutex for the whole audit window; + // re-acquiring here would deadlock. + return Ok(None); + } + use plm::coordination::singleton::{try_acquire, AcquireError}; + match try_acquire(&PLM_SINGLETON_HANDLE) { + Ok(()) => Ok(Some(AcquiredSingleton)), + Err(AcquireError::AlreadyHeld) => anyhow::bail!( + "another PLM trace is already in progress (Global\\Mxc_Plm_Audit held); \ + refusing to start a second concurrent trace — only one NT Kernel Logger \ + session can exist per host" + ), + Err(AcquireError::CreateFailed(e)) => { + Err(e).context("CreateMutexW failed for Global\\Mxc_Plm_Audit") + } + } +} + +/// Windows console-control handler. Fires on Ctrl+C, Ctrl+Break, +/// console close, logoff, and shutdown. Tears down any in-flight WPR +/// session and releases the singleton mutex before the default handler +/// calls `ExitProcess` (which skips Rust destructors). +/// +/// We poll `PLM_LOG_START_IN_FLIGHT` via `wait_until_cleared` instead +/// of a proper wait-object (Event / condvar) for two reasons: +/// 1. `wpr -start`'s underlying kernel session engagement isn't +/// signalled by any OS-published handle we can wait on; the only +/// transition we can observe is the child `wpr.exe` process +/// returning. Wrapping a Rust `Event` around that in the ctrl +/// handler would still require polling / a spawn-time helper +/// thread purely to `SetEvent`. +/// 2. The polling interval (50ms) is bounded above by +/// `CTRL_HANDLER_DRAIN_TIMEOUT` (2s) which is well under +/// Windows's ~5s ctrl-handler kill budget, so at most ~40 polls +/// fire — negligible CPU, zero cost on the happy path (the flag +/// is normally already clear when the handler runs). +#[cfg(target_os = "windows")] +unsafe extern "system" fn plm_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { + // if `plm log`'s `wpr -start` is + // still in flight when Ctrl+C arrives, briefly wait for it to + // settle before deciding whether to issue `wpr -cancel`. Without + // this wait, a cancel that races a not-yet-engaged session is a + // no-op and the kernel session leaks past `plm.exe` exit. + // + // timeout sourced from the + // shared `plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT` so + // `plm.exe` and `wxc-exec`'s `dacl_ctrl_handler` cannot drift + // apart. The const docs explain the ~5s OS kill budget rationale. + // Polls via the shared `wait_until_cleared` helper so the same + // loop is tested in one place — see `plm::coordination::tests`. + let _ = wait_until_cleared( + &PLM_LOG_START_IN_FLIGHT, + plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT, + Duration::from_millis(50), + ); + cancel_active_plm_trace_from_signal(); + release_plm_singleton(); + // Return FALSE so the default handler still runs and terminates + // the process. Matches `wxc-exec`'s dacl_ctrl_handler pattern. + windows::core::BOOL(0) +} + +#[cfg(target_os = "windows")] +fn install_ctrl_handler() { + use windows::Win32::System::Console::SetConsoleCtrlHandler; + // SAFETY: handler has the correct ABI; Add=TRUE merely appends to + // the OS handler chain. + let _ = unsafe { SetConsoleCtrlHandler(Some(plm_ctrl_handler), true) }; +} + +#[derive(Parser, Debug)] +#[command( + name = "plm", + about = "Rust port of the permissive learning mode PowerShell scripts.", + version +)] +#[cfg(target_os = "windows")] +struct Cli { + /// Internal handshake flag used by `wxc-exec --audit` to hand off + /// a directory the elevated `plm.exe` writes its stdout/stderr + /// into. See `redirect_stdio_from_argv`. Hidden from `--help`; + /// not part of the user-facing CLI. Declared here so clap accepts + /// (and ignores) the flag during subcommand parsing. + #[arg(long = "wxc-capture-dir", hide = true)] + _wxc_capture_dir: Option, + + /// Internal handshake flag used by `wxc-exec --audit` to tell us + /// it already holds the `Global\Mxc_Plm_Audit` singleton so we + /// skip acquisition and avoid a deadlock. Companion of + /// `--wxc-capture-dir`; both migrated off the previous env-var + /// mechanism because `ShellExecuteExW` + `runas` does not + /// propagate environment across the elevation boundary. + #[arg(long = "wxc-singleton-held-by-parent", hide = true)] + wxc_singleton_held_by_parent: bool, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +#[cfg(target_os = "windows")] +enum Cmd { + /// Start a new WPR trace using plm.wprp!AccessFailureProfile. + Start { + /// Override path to plm.wprp. Defaults to \plm.wprp. + #[arg(long)] + wprp: Option, + }, + /// Stop the trace and write `trace.etl` into a log directory. + Stop { + /// Directory for trace.etl, copied input config, and Adjusted_*.json. + #[arg(long)] + log_dir: Option, + /// Path treated as the application binary's location. Defaults + /// to the directory containing the plm executable. Used as the + /// self-access filter root in the adjusted config. + #[arg(long)] + bin_path: Option, + /// Path to the MXC container config (JSON) to update. + #[arg(long)] + config_path: Option, + /// Re-process a previously captured .etl instead of stopping a + /// live WPR session. When set, `wpr -stop` is skipped and the + /// supplied file is parsed as-is. + #[arg(long)] + trace_file: Option, + /// Exact destination for the ETL produced by `wpr -stop`. + #[arg(long, conflicts_with = "trace_file")] + trace_output: Option, + /// Workload exit code to record in the canonical denials JSON. + #[arg(long, default_value_t = 0)] + exit_code: i32, + /// Emit per-event/per-ACE diagnostic output. + #[arg(long)] + verbose_logging: bool, + }, + /// Run extract_caps on a hex-encoded ACE blob and print matched + /// capability names. Mirrors the standalone usage of extract_caps.ps1. + ExtractCaps { + /// Hex-encoded ACE buffer (whitespace allowed, even length). + #[arg(long)] + hex_bytes: String, + /// Emit per-ACE diagnostic output. + #[arg(long)] + verbose_logging: bool, + }, + /// Interactive: press Enter to start logging, press Enter again to stop. + Log { + /// Override path to plm.wprp. Defaults to \plm.wprp. + #[arg(long)] + wprp: Option, + /// Emit per-event/per-ACE diagnostic output. + #[arg(long)] + verbose_logging: bool, + }, +} + +#[cfg(target_os = "windows")] +fn exe_dir() -> Result { + let exe = std::env::current_exe().context("failed to resolve current exe path")?; + Ok(exe + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from("."))) +} + +/// Scan argv for `--wxc-capture-dir ` and, if present, redirect +/// this process's stdout/stderr to `/stdout.log` and +/// `/stderr.log`. Called before `Cli::parse()` so any error the +/// runtime prints (including our own arg-parse errors) reaches the +/// capture files. +/// +/// Used when `wxc-exec --audit` launches us elevated via +/// `ShellExecuteExW` + `runas`. That elevation path can inherit +/// neither our stdio handles nor our environment block (the AppInfo +/// service creates the child with a fresh env for the elevated +/// token), so environment-variable–based handoff of the capture +/// paths does not work — we must pass them on the command line. The +/// flag is also declared as a hidden `#[arg(long, hide = true)]` on +/// `Cli` so clap accepts (and ignores) it during subcommand parsing. +/// +/// On file-open failure we silently fall through — the operator +/// loses that stream's diagnostics but the rest of plm still runs. +#[cfg(target_os = "windows")] +fn redirect_stdio_from_argv() { + use std::fs::OpenOptions; + use std::os::windows::io::AsRawHandle; + use std::path::Path; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE}; + + let argv: Vec = std::env::args_os().collect(); + let mut dir: Option = None; + let mut i = 1; + while i < argv.len() { + if argv[i] == "--wxc-capture-dir" && i + 1 < argv.len() { + dir = Some(std::path::PathBuf::from(&argv[i + 1])); + break; + } + i += 1; + } + let Some(dir) = dir else { return }; + + fn redirect_one(path: &Path, which: windows::Win32::System::Console::STD_HANDLE) { + // `create_new(true)` maps to `CREATE_NEW` on Windows, which + // fails with `ERROR_FILE_EXISTS` if anything (regular file, + // directory, symlink, junction target — any reparse point) + // already occupies the path. Combined with the caller-side + // random-suffix temp dir (see `plm_launch::run_plm_elevated`), + // this closes the elevation-boundary symlink attack: a same- + // user medium-IL attacker cannot pre-plant `stdout.log` / + // `stderr.log` as a symlink pointing at an admin-only file + // and have this elevated (admin-token) process silently + // append attacker-controllable bytes to that target. + // + // If create_new fails (attacker successfully raced us, or + // some other fs error) we silently give up — the operator + // loses that stream's diagnostics but no privilege boundary + // is crossed. + let Ok(f) = OpenOptions::new().create_new(true).append(true).open(path) else { + return; + }; + let handle = HANDLE(f.as_raw_handle()); + // Leak the file so the handle stays alive for the process's + // lifetime. `SetStdHandle` records the raw handle; if the + // File drops, the handle closes and subsequent writes fail. + std::mem::forget(f); + // SAFETY: `which` is a valid STD_* constant; `handle` was + // just returned from OpenOptions::open and remains valid + // because we forgot the File. + let _ = unsafe { SetStdHandle(which, handle) }; + } + + redirect_one(&dir.join("stdout.log"), STD_OUTPUT_HANDLE); + redirect_one(&dir.join("stderr.log"), STD_ERROR_HANDLE); +} + +#[cfg(target_os = "windows")] +fn main() -> Result<()> { + // If wxc-exec spawned us elevated via ShellExecuteExW+runas, it + // cannot inherit our stdio pipes across the elevation boundary + // AND the AppInfo service that brokers the elevation does not + // propagate our environment block to the elevated child. The + // capture-file directory is therefore passed as a hidden CLI + // argument (`--wxc-capture-dir`) rather than via env; we redirect + // stdout/stderr to files inside it before touching clap so any + // arg-parse errors also reach the operator. Silent no-op when + // the flag is absent (direct user invocation from an elevated + // shell). + redirect_stdio_from_argv(); + + let cli = Cli::parse(); + // Honour the parent-holds-singleton signal wxc-exec passed as a + // CLI flag. Set BEFORE any acquire_singleton_if_needed call so + // the bypass fires. We keep the env-var path in + // singleton_bypass_requested as a compatibility fallback for + // direct callers that inherit env normally (see coordination.rs). + if cli.wxc_singleton_held_by_parent { + plm::coordination::set_singleton_bypass_override(true); + } + let exe = exe_dir()?; + + // Confirm the resolved wpr.exe exists at `%SystemDirectory%` + // before we go further. We rely on `GetSystemDirectoryW` (not + // env-spoofable) plus the OS TrustedInstaller ACL on that + // directory as the trust boundary; see `wpr_path` module docs for + // why we do not run WinVerifyTrust on the resolved binary. + plm::wpr_path::verify_wpr_present() + .map_err(|e| anyhow::anyhow!("wpr.exe check failed: {e}"))?; + + // Install the Ctrl+C handler unconditionally so signals during any + // subcommand (in particular interactive `log`) tear down the WPR + // session and release the singleton before ExitProcess fires. + install_ctrl_handler(); + + match cli.cmd { + Cmd::Start { wprp } => { + let _singleton = acquire_singleton_if_needed()?; + // Default: materialize the embedded `plm.wprp` next to the + // exe if one isn't already there. + let wprp_path = match wprp { + Some(p) => p, + None => profile_gen::ensure_wprp_next_to_exe(&exe) + .context("failed to stage plm.wprp next to plm.exe")?, + }; + start::start_plm_trace(&wprp_path)?; + // `plm start` exits immediately and leaves the kernel ETW + // session running until a later `plm stop` / `wpr -stop`. + // We deliberately do NOT mark PLM_TRACE_ACTIVE here: this + // process is about to exit and can't be the one to cancel + // the session it just kicked off. The matching `plm stop` + // (or wxc-exec's `cancel_active_audit_trace` cleanup path + // on Ctrl+C) is what owns teardown. + Ok(()) + } + Cmd::Stop { + log_dir, + bin_path, + config_path, + trace_file, + trace_output, + exit_code, + verbose_logging, + } => { + let _singleton = acquire_singleton_if_needed()?; + let result = stop::run( + stop::StopOptions { + log_dir, + bin_path, + config_path, + trace_file, + trace_output, + exit_code, + verbose: verbose_logging, + }, + &exe, + )?; + println!("{}", serde_json::to_string(&result)?); + Ok(()) + } + Cmd::ExtractCaps { + hex_bytes, + verbose_logging, + } => { + let caps = extract_caps::extract_caps(&hex_bytes, verbose_logging)?; + for c in extract_caps::sorted_capability_names(&caps) { + println!("{c}"); + } + Ok(()) + } + Cmd::Log { + wprp, + verbose_logging, + } => { + let singleton = acquire_singleton_if_needed()?; + // see `Cmd::Start` above — stage the embedded profile if + // missing. + let wprp_path = match wprp { + Some(p) => p, + None => profile_gen::ensure_wprp_next_to_exe(&exe) + .context("failed to stage plm.wprp next to plm.exe")?, + }; + // The interactive `log` flow is the only standalone path + // that holds a live trace inside a single process. We hand + // `log::run` closures that call + // `AcquiredSingleton::mark_trace_active` / + // `clear_trace_active` on the borrowed singleton — the + // `&AcquiredSingleton` methods encode at compile time that + // trace-active can only be set while we hold the host-wide + // singleton mutex. `mark_trace_active` flips the flag only + // AFTER `wpr -start` has actually engaged the kernel + // session, so a stdin-EOF or spawn-fail before that point + // cannot trip the Ctrl+C handler into `wpr -cancel`ing an + // unrelated host WPR session. + let result = if let Some(s) = singleton.as_ref() { + log::run( + &wprp_path, + verbose_logging, + || s.mark_trace_active(), + || s.clear_trace_active(), + ) + } else { + // Singleton bypass path (wxc-exec --audit already + // holds the mutex). No `AcquiredSingleton` exists in + // this process, so we can't gate the flag on it — + // fall back to the free-function path that the ctrl + // handler also uses. The outer process owns cleanup. + log::run( + &wprp_path, + verbose_logging, + || PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst), + || PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst), + ) + }; + // If `log::run` returned Err AND the trace had been marked + // active (start succeeded but stop or later step failed), + // the flag is still set — issue `wpr -cancel` so the NT + // Kernel Logger session doesn't leak until reboot. + if result.is_err() { + if let Some(s) = singleton.as_ref() { + s.cancel_active_trace(); + } else { + cancel_active_plm_trace_from_signal(); + } + } + result + } + } +} From cd62e41df66c3de3eeb67cdaed54e4600d42c440 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 7 Aug 2026 15:15:17 -0700 Subject: [PATCH 04/50] Harden PLM denial compatibility Normalize non-verbatim Win32 output aliases during collision checks and keep metadata-only READ_CONTROL denials from producing content-access grants. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../learning_mode/windows/src/extractors.rs | 21 ++-- src/host/plm/src/stop.rs | 95 ++++++++++++++++++- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 2f2850e5f..a95cac58f 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -323,7 +323,6 @@ fn parse_u32(raw: &str) -> Option { fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { // Standard rights (object-type independent). const DELETE: u32 = 0x0001_0000; - const READ_CONTROL: u32 = 0x0002_0000; const WRITE_DAC: u32 = 0x0004_0000; const WRITE_OWNER: u32 = 0x0008_0000; // Generic rights (object-type independent). @@ -343,12 +342,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { const KEY_NOTIFY: u32 = 0x0010; const KEY_CREATE_LINK: u32 = 0x0020; ( - KEY_QUERY_VALUE - | KEY_ENUMERATE_SUB_KEYS - | KEY_NOTIFY - | READ_CONTROL - | GENERIC_READ - | GENERIC_EXECUTE, + KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | GENERIC_READ | GENERIC_EXECUTE, KEY_SET_VALUE | KEY_CREATE_SUB_KEY | KEY_CREATE_LINK @@ -370,7 +364,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { const FILE_READ_ATTRIBUTES: u32 = 0x0080; const FILE_WRITE_ATTRIBUTES: u32 = 0x0100; ( - FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | READ_CONTROL | GENERIC_READ, + FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | GENERIC_READ, FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA @@ -790,7 +784,12 @@ mod tests { #[test] fn file_mask_no_recognised_right_is_unknown() { - // SYNCHRONIZE (0x100000) alone and MAXIMUM_ALLOWED (0x02000000) alone. + // READ_CONTROL, SYNCHRONIZE, and MAXIMUM_ALLOWED alone grant no + // file-content access and must not become readonly recommendations. + assert_eq!( + access_type_from_mask(0x0002_0000, false), + AccessType::Unknown + ); assert_eq!( access_type_from_mask(0x0010_0000, false), AccessType::Unknown @@ -815,5 +814,9 @@ mod tests { assert_eq!(access_type_from_mask(0x0020, true), AccessType::Write); // KEY_CREATE_LINK (execute for files!) // Registry has no execute concept: 0x20 is a write here, not execute. assert_ne!(access_type_from_mask(0x0020, true), AccessType::Execute); + assert_eq!( + access_type_from_mask(0x0002_0000, true), + AccessType::Unknown + ); } } diff --git a/src/host/plm/src/stop.rs b/src/host/plm/src/stop.rs index edcb4d0df..4adf5ddc4 100644 --- a/src/host/plm/src/stop.rs +++ b/src/host/plm/src/stop.rs @@ -361,6 +361,8 @@ fn same_config_target(a: &Path, b: &Path) -> bool { } fn target_comparison_key(path: &Path) -> String { + let original = path.to_string_lossy().replace('/', "\\"); + let is_verbatim = original.starts_with(r"\\?\"); let resolved = std::fs::canonicalize(path) .or_else(|_| { let parent = path @@ -375,16 +377,44 @@ fn target_comparison_key(path: &Path) -> String { }) .or_else(|_| std::path::absolute(path)) .unwrap_or_else(|_| path.to_path_buf()); - let key = resolved.to_string_lossy().replace('/', "\\"); let key = key .strip_prefix(r"\\?\UNC\") .map(|rest| format!(r"\\{rest}")) .or_else(|| key.strip_prefix(r"\\?\").map(str::to_string)) .unwrap_or(key); + let key = if is_verbatim { + key + } else { + normalize_nonverbatim_win32_components(&key) + }; key.to_ascii_lowercase() } +fn normalize_nonverbatim_win32_components(path: &str) -> String { + path.split('\\') + .map(|component| { + if component.is_empty() || component.ends_with(':') { + component + } else { + let default_stream_suffix = "::$DATA"; + let component = component + .get(..component.len().saturating_sub(default_stream_suffix.len())) + .filter(|_| { + component + .get(component.len().saturating_sub(default_stream_suffix.len())..) + .is_some_and(|suffix| { + suffix.eq_ignore_ascii_case(default_stream_suffix) + }) + }) + .unwrap_or(component); + component.trim_end_matches([' ', '.']) + } + }) + .collect::>() + .join("\\") +} + #[cfg(test)] mod tests { use super::*; @@ -516,6 +546,36 @@ mod tests { assert!(error.to_string().contains("would be overwritten")); } + #[test] + fn trailing_dot_trace_alias_cannot_collide_with_denials_output() { + let dir = std::env::temp_dir().join(format!("plm_alias_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let error = prepare_config_output_paths( + None, + &dir, + &dir.join("denials.json."), + &dir.join("denials.json"), + ) + .unwrap_err(); + assert!(error.to_string().contains("would be overwritten")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn default_stream_trace_alias_cannot_collide_with_denials_output() { + let dir = std::env::temp_dir().join(format!("plm_stream_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let error = prepare_config_output_paths( + None, + &dir, + &dir.join("denials.json::$DATA"), + &dir.join("denials.json"), + ) + .unwrap_err(); + assert!(error.to_string().contains("would be overwritten")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn source_config_collision_is_rejected_before_capture() { let trace = Path::new(r"C:\captures\trace.etl"); @@ -559,4 +619,37 @@ mod tests { assert!(same_config_target(&lower, &upper)); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn same_config_target_normalizes_trailing_dot_for_new_outputs() { + let dir = std::env::temp_dir().join(format!("plm_dot_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(same_config_target( + &dir.join("denials.json."), + &dir.join("denials.json") + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn same_config_target_normalizes_trailing_space_for_new_outputs() { + let dir = std::env::temp_dir().join(format!("plm_space_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(same_config_target( + &dir.join("denials.json "), + &dir.join("denials.json") + )); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn same_config_target_normalizes_default_stream_for_new_outputs() { + let dir = std::env::temp_dir().join(format!("plm_stream_key_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(same_config_target( + &dir.join("denials.json::$data"), + &dir.join("denials.json") + )); + let _ = std::fs::remove_dir_all(&dir); + } } From 8af915fe356248ebb220b076f3f1a20fbdac35f9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 7 Aug 2026 16:25:44 -0700 Subject: [PATCH 05/50] Close PLM collision edge cases Compare existing outputs by filesystem identity, fail closed when identity cannot be examined, normalize default-stream aliases, and preserve canonical workload self-access filtering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../learning_mode/windows/src/extractors.rs | 84 +++++++++++++++++ src/core/wxc_common/src/filesystem_object.rs | 45 ++++++++- src/host/plm/src/stop.rs | 91 +++++++++++++++++-- 3 files changed, 205 insertions(+), 15 deletions(-) diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index a95cac58f..713688ed6 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -176,6 +176,15 @@ pub fn build_denial_from_access_check( .map(|v| v.trim_matches('"').to_string()) .filter(|name| !name.is_empty())?; + if resource_type == ResourceType::File { + let app_path = find_prop(&parts.props, "AppPath") + .or_else(|| find_prop(&parts.props, "ApplicationPath")) + .map(|value| value.trim_matches('"')); + if app_path.is_some_and(|app_path| is_self_access(&object_name, app_path)) { + return None; + } + } + let access_type = if resource_type == ResourceType::Capability { // Capability checks report a mask (often 0x1) that is not a // read/write/execute verb, so don't run the file/registry @@ -202,6 +211,48 @@ pub fn build_denial_from_access_check( }) } +fn is_self_access(object_name: &str, app_path: &str) -> bool { + let object_name = strip_dos_namespace_prefix(object_name); + let app_path = strip_dos_namespace_prefix(app_path); + match ( + volume_relative_path(object_name), + volume_relative_path(app_path), + ) { + (Some(object_relative), Some(app_relative)) => { + !object_relative.is_empty() && object_relative.eq_ignore_ascii_case(app_relative) + } + _ => false, + } +} + +fn strip_dos_namespace_prefix(path: &str) -> &str { + for prefix in [r"\??\", r"\\?\", r"\\.\"] { + if let Some(path) = path.strip_prefix(prefix) { + return path; + } + } + path +} + +fn volume_relative_path(path: &str) -> Option<&str> { + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { + return path.get(2..); + } + + const VOLUME_PREFIX: &str = r"\Device\HarddiskVolume"; + if path + .get(..VOLUME_PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(VOLUME_PREFIX)) + { + return path + .get(VOLUME_PREFIX.len()..)? + .find('\\') + .and_then(|separator| path.get(VOLUME_PREFIX.len() + separator..)); + } + None +} + /// Builds a [`RawDenial`] from a `LearningModeViolation` (event 27) payload. /// /// These represent UI-surface denials. `Category` identifies the class and @@ -478,6 +529,39 @@ mod tests { assert_eq!(ev.access_type, AccessType::Write); } + #[test] + fn access_check_drops_workload_self_access() { + for app_path in [ + r#""\Device\HarddiskVolume3\Tools\app.exe""#, + r#""C:\Tools\app.exe""#, + ] { + let p = parts( + 14, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", r#""\??\C:\Tools\App.EXE""#), + ("AppPath", app_path), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + } + + #[test] + fn access_check_keeps_same_name_at_different_path() { + let p = parts( + 14, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", r#""C:\app.exe""#), + ("AppPath", r#""\Device\HarddiskVolume3\Tools\app.exe""#), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_some()); + } + #[test] fn access_check_key_denial_uses_registry_vocabulary() { let p = parts( diff --git a/src/core/wxc_common/src/filesystem_object.rs b/src/core/wxc_common/src/filesystem_object.rs index c9e88bf84..33f604bb7 100644 --- a/src/core/wxc_common/src/filesystem_object.rs +++ b/src/core/wxc_common/src/filesystem_object.rs @@ -36,6 +36,7 @@ use crate::logger::Logger; use crate::models::ContainerPolicy; +use std::path::Path; /// Intent class for a policy path, ordered least → most restrictive so that /// `max()` yields the strictest intent in a group of aliases. @@ -94,6 +95,17 @@ enum PathResolution { Unknown, } +/// Result of comparing two paths by filesystem-object identity. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ExistingObjectComparison { + /// Both paths resolved to the same object. + Same, + /// At least one path is absent, or both resolved to different objects. + Different, + /// At least one existing or potentially existing path could not be examined. + Unknown, +} + /// Resolve a path to its filesystem-object identity, following symlinks so two /// names for the same target collide. /// @@ -102,7 +114,7 @@ enum PathResolution { /// ([`PathResolution::Unknown`]), so the caller can fail closed on the latter /// without rejecting the common "path created at mount time" case. #[cfg(unix)] -fn resolve_object(path: &str) -> PathResolution { +fn resolve_object(path: &Path) -> PathResolution { use std::os::unix::fs::MetadataExt; // `metadata` follows symlinks, giving the target object's identity. match std::fs::metadata(path) { @@ -121,7 +133,8 @@ fn resolve_object(path: &str) -> PathResolution { } #[cfg(windows)] -fn resolve_object(path: &str) -> PathResolution { +fn resolve_object(path: &Path) -> PathResolution { + use std::os::windows::ffi::OsStrExt; use windows::core::PCWSTR; use windows::Win32::Foundation::{ CloseHandle, GetLastError, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, @@ -132,7 +145,11 @@ fn resolve_object(path: &str) -> PathResolution { OPEN_EXISTING, }; - let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); let share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; // FILE_READ_ATTRIBUTES is the minimum access GetFileInformationByHandleEx @@ -192,12 +209,30 @@ fn resolve_object(path: &str) -> PathResolution { } #[cfg(not(any(unix, windows)))] -fn resolve_object(_path: &str) -> PathResolution { +fn resolve_object(_path: &Path) -> PathResolution { // No way to determine object identity on unsupported platforms; treat as // unexaminable so the fail-closed path applies when deniedPaths are present. PathResolution::Unknown } +/// Compare two paths by filesystem-object identity. +pub fn compare_existing_filesystem_objects(a: &Path, b: &Path) -> ExistingObjectComparison { + match (resolve_object(a), resolve_object(b)) { + (PathResolution::Object(a), PathResolution::Object(b)) if a == b => { + ExistingObjectComparison::Same + } + (PathResolution::Absent, _) | (_, PathResolution::Absent) => { + ExistingObjectComparison::Different + } + (PathResolution::Unknown, _) | (_, PathResolution::Unknown) => { + ExistingObjectComparison::Unknown + } + (PathResolution::Object(_), PathResolution::Object(_)) => { + ExistingObjectComparison::Different + } + } +} + /// Detect cross-path object conflicts and return a tightened copy of `policy`. /// /// For each set of policy paths that resolve to the same filesystem object but @@ -254,7 +289,7 @@ pub fn normalize_object_conflicts( let has_denied = !policy.denied_paths.is_empty(); let mut groups: HashMap> = HashMap::new(); for (i, (path, intent)) in entries.iter().enumerate() { - match resolve_object(path) { + match resolve_object(Path::new(path)) { PathResolution::Object(id) => { groups.entry(id).or_default().push(i); } diff --git a/src/host/plm/src/stop.rs b/src/host/plm/src/stop.rs index 4adf5ddc4..cdb368438 100644 --- a/src/host/plm/src/stop.rs +++ b/src/host/plm/src/stop.rs @@ -354,10 +354,19 @@ pub fn run(opts: StopOptions, exe_dir: &Path) -> Result { /// /// Existing files are canonicalized directly. For a not-yet-created output, /// the existing parent is canonicalized before the leaf is reattached, which -/// still resolves junctions, symlinks, short names, and `.`/`..`. The final -/// comparison is case-insensitive because Windows paths are case-insensitive. +/// still resolves junctions, symlinks, short names, and `.`/`..`. Existing +/// targets are also compared by volume/file ID so hard links cannot bypass the +/// pre-capture check. The final path comparison is case-insensitive because +/// Windows paths are case-insensitive. fn same_config_target(a: &Path, b: &Path) -> bool { - target_comparison_key(a) == target_comparison_key(b) + use wxc_common::filesystem_object::{ + compare_existing_filesystem_objects, ExistingObjectComparison, + }; + + match compare_existing_filesystem_objects(a, b) { + ExistingObjectComparison::Same | ExistingObjectComparison::Unknown => true, + ExistingObjectComparison::Different => target_comparison_key(a) == target_comparison_key(b), + } } fn target_comparison_key(path: &Path) -> String { @@ -383,20 +392,21 @@ fn target_comparison_key(path: &Path) -> String { .map(|rest| format!(r"\\{rest}")) .or_else(|| key.strip_prefix(r"\\?\").map(str::to_string)) .unwrap_or(key); - let key = if is_verbatim { - key - } else { - normalize_nonverbatim_win32_components(&key) - }; + let key = normalize_win32_components(&key, !is_verbatim); key.to_ascii_lowercase() } -fn normalize_nonverbatim_win32_components(path: &str) -> String { +fn normalize_win32_components(path: &str, trim_trailing_dots_and_spaces: bool) -> String { path.split('\\') .map(|component| { if component.is_empty() || component.ends_with(':') { component } else { + let component = if trim_trailing_dots_and_spaces { + component.trim_end_matches([' ', '.']) + } else { + component + }; let default_stream_suffix = "::$DATA"; let component = component .get(..component.len().saturating_sub(default_stream_suffix.len())) @@ -408,7 +418,11 @@ fn normalize_nonverbatim_win32_components(path: &str) -> String { }) }) .unwrap_or(component); - component.trim_end_matches([' ', '.']) + if trim_trailing_dots_and_spaces { + component.trim_end_matches([' ', '.']) + } else { + component + } } }) .collect::>() @@ -589,6 +603,22 @@ mod tests { assert!(error.to_string().contains("source config")); } + #[test] + fn source_config_hard_link_collision_is_rejected_before_capture() { + let dir = std::env::temp_dir().join(format!("plm_hard_link_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("config.json"); + let trace = dir.join("trace.etl"); + std::fs::write(&source, "{}").unwrap(); + std::fs::hard_link(&source, &trace).unwrap(); + + let error = + prepare_config_output_paths(Some(&source), &dir, &trace, &dir.join("denials.json")) + .unwrap_err(); + assert!(error.to_string().contains("source config")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn same_config_target_matches_identical_existing_path() { // Two spellings of the same existing file must be detected as @@ -610,6 +640,18 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn same_config_target_matches_existing_hard_links() { + let dir = std::env::temp_dir().join(format!("plm_hard_link_key_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.json"); + let alias = dir.join("alias.json"); + std::fs::write(&source, "{}").unwrap(); + std::fs::hard_link(&source, &alias).unwrap(); + assert!(same_config_target(&source, &alias)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn same_config_target_is_case_insensitive_for_new_outputs() { let dir = std::env::temp_dir().join(format!("plm_case_target_{}", std::process::id())); @@ -652,4 +694,33 @@ mod tests { )); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn same_config_target_normalizes_trailing_characters_after_default_stream() { + let dir = std::env::temp_dir().join(format!("plm_stream_trim_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for alias in ["denials.json::$DATA.", "denials.json::$DATA "] { + assert!(same_config_target( + &dir.join(alias), + &dir.join("denials.json") + )); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn same_config_target_normalizes_default_stream_for_verbatim_outputs() { + assert!(same_config_target( + Path::new(r"\\?\C:\captures\denials.json::$DATA"), + Path::new(r"\\?\C:\captures\denials.json") + )); + } + + #[test] + fn same_config_target_preserves_trailing_dot_for_verbatim_outputs() { + assert!(!same_config_target( + Path::new(r"\\?\C:\captures\denials.json."), + Path::new(r"\\?\C:\captures\denials.json") + )); + } } From db9099664692eca6e159826ef72dc4af1d3f48f0 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 10:33:02 -0700 Subject: [PATCH 06/50] Harden PLM path and capability handling Use Windows ordinal Unicode case comparison for output collision checks and discard partial capability matches from malformed DACL records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../windows/src/capability_dacl.rs | 15 ++++++++++-- src/host/plm/Cargo.toml | 1 + src/host/plm/src/stop.rs | 24 ++++++++++++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/backends/learning_mode/windows/src/capability_dacl.rs b/src/backends/learning_mode/windows/src/capability_dacl.rs index 18aab9806..059f656d9 100644 --- a/src/backends/learning_mode/windows/src/capability_dacl.rs +++ b/src/backends/learning_mode/windows/src/capability_dacl.rs @@ -306,8 +306,10 @@ fn extract_names<'a>(parts: &DecodedEventParts, index: &'a CapabilityIndex) -> H let Ok(decoded) = decode_hex(candidate) else { continue; }; - let (found, _) = walk_aces(&decoded, index); - names.extend(found); + let (found, error) = walk_aces(&decoded, index); + if error.is_none() { + names.extend(found); + } } names } @@ -814,6 +816,15 @@ mod tests { assert!(matches!(error, Some(DaclDecodeError::TruncatedAce(_)))); } + #[test] + fn truncated_tail_is_not_promoted_by_extractor() { + let sid = sid(); + let index = CapabilityIndex::for_test(&[("internetClient", &sid)]); + let mut bytes = standard_ace(1, &sid); + bytes.push(0); + assert!(extract_names(&parts("Dacl", hex(&bytes)), &index).is_empty()); + } + #[test] fn truncated_legacy_ace_returns_error_instead_of_panicking() { let bytes = vec![0; STANDARD_ACE_HEADER_SIZE + SID_FIXED_HEADER_SIZE]; diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index ef1e8b405..b35dfcece 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -32,6 +32,7 @@ tempfile.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true, features = [ + "Win32_Globalization", "Win32_System_EventLog", "Win32_System_SystemInformation", "Win32_System_Threading", diff --git a/src/host/plm/src/stop.rs b/src/host/plm/src/stop.rs index cdb368438..3130eaf30 100644 --- a/src/host/plm/src/stop.rs +++ b/src/host/plm/src/stop.rs @@ -365,10 +365,21 @@ fn same_config_target(a: &Path, b: &Path) -> bool { match compare_existing_filesystem_objects(a, b) { ExistingObjectComparison::Same | ExistingObjectComparison::Unknown => true, - ExistingObjectComparison::Different => target_comparison_key(a) == target_comparison_key(b), + ExistingObjectComparison::Different => { + windows_paths_equal_ignore_case(&target_comparison_key(a), &target_comparison_key(b)) + } } } +fn windows_paths_equal_ignore_case(a: &str, b: &str) -> bool { + use windows::Win32::Globalization::{CompareStringOrdinal, CSTR_EQUAL}; + + let a: Vec = a.encode_utf16().collect(); + let b: Vec = b.encode_utf16().collect(); + // SAFETY: Both slices are valid UTF-16 buffers for the duration of the call. + unsafe { CompareStringOrdinal(&a, &b, true) == CSTR_EQUAL } +} + fn target_comparison_key(path: &Path) -> String { let original = path.to_string_lossy().replace('/', "\\"); let is_verbatim = original.starts_with(r"\\?\"); @@ -392,8 +403,7 @@ fn target_comparison_key(path: &Path) -> String { .map(|rest| format!(r"\\{rest}")) .or_else(|| key.strip_prefix(r"\\?\").map(str::to_string)) .unwrap_or(key); - let key = normalize_win32_components(&key, !is_verbatim); - key.to_ascii_lowercase() + normalize_win32_components(&key, !is_verbatim) } fn normalize_win32_components(path: &str, trim_trailing_dots_and_spaces: bool) -> String { @@ -662,6 +672,14 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn same_config_target_is_unicode_case_insensitive_for_new_outputs() { + let dir = std::env::temp_dir().join(format!("plm_unicode_target_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(same_config_target(&dir.join("Ä.etl"), &dir.join("ä.etl"))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn same_config_target_normalizes_trailing_dot_for_new_outputs() { let dir = std::env::temp_dir().join(format!("plm_dot_target_{}", std::process::id())); From cf1a4ad834f7e1e9424337d9eb284c416eb44b7a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 11:41:46 -0700 Subject: [PATCH 07/50] Preserve volume identity in PLM filtering Compare complete paths when both self-access operands use the same namespace, and only use volume-relative matching for mixed DOS and device paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- .../learning_mode/windows/src/extractors.rs | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 713688ed6..bf7755839 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -214,17 +214,44 @@ pub fn build_denial_from_access_check( fn is_self_access(object_name: &str, app_path: &str) -> bool { let object_name = strip_dos_namespace_prefix(object_name); let app_path = strip_dos_namespace_prefix(app_path); - match ( - volume_relative_path(object_name), - volume_relative_path(app_path), - ) { - (Some(object_relative), Some(app_relative)) => { - !object_relative.is_empty() && object_relative.eq_ignore_ascii_case(app_relative) + match (path_namespace(object_name), path_namespace(app_path)) { + (Some(object_namespace), Some(app_namespace)) if object_namespace == app_namespace => { + object_name.eq_ignore_ascii_case(app_path) + } + (Some(_), Some(_)) => { + match ( + volume_relative_path(object_name), + volume_relative_path(app_path), + ) { + (Some(object_relative), Some(app_relative)) => { + !object_relative.is_empty() + && object_relative.eq_ignore_ascii_case(app_relative) + } + _ => false, + } } _ => false, } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PathNamespace { + Dos, + DeviceVolume, +} + +fn path_namespace(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\' { + return Some(PathNamespace::Dos); + } + + const VOLUME_PREFIX: &str = r"\Device\HarddiskVolume"; + path.get(..VOLUME_PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(VOLUME_PREFIX)) + .then_some(PathNamespace::DeviceVolume) +} + fn strip_dos_namespace_prefix(path: &str) -> &str { for prefix in [r"\??\", r"\\?\", r"\\.\"] { if let Some(path) = path.strip_prefix(prefix) { @@ -548,6 +575,34 @@ mod tests { } } + #[test] + fn access_check_preserves_same_suffix_on_different_dos_drives() { + let p = parts( + 14, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", r#""C:\Tools\app.exe""#), + ("AppPath", r#""D:\Tools\app.exe""#), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_some()); + } + + #[test] + fn access_check_preserves_same_suffix_on_different_device_volumes() { + let p = parts( + 14, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", r#""\Device\HarddiskVolume3\Tools\app.exe""#), + ("AppPath", r#""\Device\HarddiskVolume4\Tools\app.exe""#), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_some()); + } + #[test] fn access_check_keeps_same_name_at_different_path() { let p = parts( From d366e09e3375c9271d3f3847b3ac96d5e0b96be1 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 12:00:22 -0700 Subject: [PATCH 08/50] Fail closed on dangling filesystem links Inspect missing leaves without following reparse points so dangling links remain unexaminable, and make unknown identity dominate an absent counterpart during collision checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/core/wxc_common/src/filesystem_object.rs | 84 ++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/src/core/wxc_common/src/filesystem_object.rs b/src/core/wxc_common/src/filesystem_object.rs index 33f604bb7..fcb22305e 100644 --- a/src/core/wxc_common/src/filesystem_object.rs +++ b/src/core/wxc_common/src/filesystem_object.rs @@ -100,7 +100,8 @@ enum PathResolution { pub enum ExistingObjectComparison { /// Both paths resolved to the same object. Same, - /// At least one path is absent, or both resolved to different objects. + /// At least one path is absent and neither is unknown, or both resolved to + /// different objects. Different, /// At least one existing or potentially existing path could not be examined. Unknown, @@ -126,6 +127,19 @@ fn resolve_object(path: &Path) -> PathResolution { // untraversable parent, ESTALE/ETIMEDOUT from a dead mount, ...) means we // could not examine the path. Err(e) => match e.raw_os_error() { + Some(libc::ENOENT) | Some(libc::ENOTDIR) => classify_not_found_leaf_unix(path), + _ => PathResolution::Unknown, + }, + } +} + +#[cfg(unix)] +fn classify_not_found_leaf_unix(path: &Path) -> PathResolution { + match std::fs::symlink_metadata(path) { + // The leaf exists without following links, so the failed target lookup + // may be a dangling symlink. Its aliasing cannot be ruled out. + Ok(_) => PathResolution::Unknown, + Err(error) => match error.raw_os_error() { Some(libc::ENOENT) | Some(libc::ENOTDIR) => PathResolution::Absent, _ => PathResolution::Unknown, }, @@ -176,7 +190,7 @@ fn resolve_object(path: &Path) -> PathResolution { // SAFETY: reads the thread-local last error set by the failed call. let err = unsafe { GetLastError() }; return if err == ERROR_FILE_NOT_FOUND || err == ERROR_PATH_NOT_FOUND { - PathResolution::Absent + classify_not_found_leaf_windows(path) } else { PathResolution::Unknown }; @@ -208,6 +222,27 @@ fn resolve_object(path: &Path) -> PathResolution { }) } +#[cfg(windows)] +fn classify_not_found_leaf_windows(path: &Path) -> PathResolution { + use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND}; + + match std::fs::symlink_metadata(path) { + // `symlink_metadata` examines the leaf without following its reparse + // point. Success here means the followed open may have failed because + // the leaf is a dangling symlink/junction, which must fail closed. + Ok(_) => PathResolution::Unknown, + Err(error) => match error.raw_os_error() { + Some(code) + if code == ERROR_FILE_NOT_FOUND.0 as i32 + || code == ERROR_PATH_NOT_FOUND.0 as i32 => + { + PathResolution::Absent + } + _ => PathResolution::Unknown, + }, + } +} + #[cfg(not(any(unix, windows)))] fn resolve_object(_path: &Path) -> PathResolution { // No way to determine object identity on unsupported platforms; treat as @@ -221,12 +256,12 @@ pub fn compare_existing_filesystem_objects(a: &Path, b: &Path) -> ExistingObject (PathResolution::Object(a), PathResolution::Object(b)) if a == b => { ExistingObjectComparison::Same } - (PathResolution::Absent, _) | (_, PathResolution::Absent) => { - ExistingObjectComparison::Different - } (PathResolution::Unknown, _) | (_, PathResolution::Unknown) => { ExistingObjectComparison::Unknown } + (PathResolution::Absent, _) | (_, PathResolution::Absent) => { + ExistingObjectComparison::Different + } (PathResolution::Object(_), PathResolution::Object(_)) => { ExistingObjectComparison::Different } @@ -546,6 +581,45 @@ mod tests { assert!(out.readonly_paths.contains(&b.to_string())); } + #[cfg(windows)] + #[test] + fn dangling_symlink_is_unknown_even_when_target_is_absent() { + use std::os::windows::fs::symlink_file; + + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("missing-target"); + let link = dir.path().join("dangling-link"); + if let Err(error) = symlink_file(&target, &link) { + // Creating symlinks requires Developer Mode or SeCreateSymbolicLink + // privilege on some supported Windows hosts. + if error.raw_os_error() == Some(1314) { + return; + } + panic!("failed to create dangling symlink: {error}"); + } + + assert!(matches!(resolve_object(&link), PathResolution::Unknown)); + assert_eq!( + compare_existing_filesystem_objects(&link, &target), + ExistingObjectComparison::Unknown + ); + } + + #[cfg(unix)] + #[test] + fn dangling_symlink_is_unknown_even_when_target_is_absent_unix() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("missing-target"); + let link = dir.path().join("dangling-link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert!(matches!(resolve_object(&link), PathResolution::Unknown)); + assert_eq!( + compare_existing_filesystem_objects(&link, &target), + ExistingObjectComparison::Unknown + ); + } + #[cfg(unix)] #[test] fn denied_wins_over_both_rw_and_ro_aliases() { From 1809fafdfd04dd9200a327fab06ae1a7e93ecfe0 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 12:53:21 -0700 Subject: [PATCH 09/50] Scope dangling-link handling to Windows Keep the PLM collision hardening in the Windows resolver without changing Unix filesystem-object semantics or tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/core/wxc_common/src/filesystem_object.rs | 37 ++++---------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/src/core/wxc_common/src/filesystem_object.rs b/src/core/wxc_common/src/filesystem_object.rs index fcb22305e..2f2d747e4 100644 --- a/src/core/wxc_common/src/filesystem_object.rs +++ b/src/core/wxc_common/src/filesystem_object.rs @@ -100,8 +100,8 @@ enum PathResolution { pub enum ExistingObjectComparison { /// Both paths resolved to the same object. Same, - /// At least one path is absent and neither is unknown, or both resolved to - /// different objects. + /// At least one path is absent (on Windows, with neither path unknown), or + /// both resolved to different objects. Different, /// At least one existing or potentially existing path could not be examined. Unknown, @@ -127,19 +127,6 @@ fn resolve_object(path: &Path) -> PathResolution { // untraversable parent, ESTALE/ETIMEDOUT from a dead mount, ...) means we // could not examine the path. Err(e) => match e.raw_os_error() { - Some(libc::ENOENT) | Some(libc::ENOTDIR) => classify_not_found_leaf_unix(path), - _ => PathResolution::Unknown, - }, - } -} - -#[cfg(unix)] -fn classify_not_found_leaf_unix(path: &Path) -> PathResolution { - match std::fs::symlink_metadata(path) { - // The leaf exists without following links, so the failed target lookup - // may be a dangling symlink. Its aliasing cannot be ruled out. - Ok(_) => PathResolution::Unknown, - Err(error) => match error.raw_os_error() { Some(libc::ENOENT) | Some(libc::ENOTDIR) => PathResolution::Absent, _ => PathResolution::Unknown, }, @@ -256,12 +243,17 @@ pub fn compare_existing_filesystem_objects(a: &Path, b: &Path) -> ExistingObject (PathResolution::Object(a), PathResolution::Object(b)) if a == b => { ExistingObjectComparison::Same } + #[cfg(windows)] (PathResolution::Unknown, _) | (_, PathResolution::Unknown) => { ExistingObjectComparison::Unknown } (PathResolution::Absent, _) | (_, PathResolution::Absent) => { ExistingObjectComparison::Different } + #[cfg(not(windows))] + (PathResolution::Unknown, _) | (_, PathResolution::Unknown) => { + ExistingObjectComparison::Unknown + } (PathResolution::Object(_), PathResolution::Object(_)) => { ExistingObjectComparison::Different } @@ -605,21 +597,6 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn dangling_symlink_is_unknown_even_when_target_is_absent_unix() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("missing-target"); - let link = dir.path().join("dangling-link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - assert!(matches!(resolve_object(&link), PathResolution::Unknown)); - assert_eq!( - compare_existing_filesystem_objects(&link, &target), - ExistingObjectComparison::Unknown - ); - } - #[cfg(unix)] #[test] fn denied_wins_over_both_rw_and_ro_aliases() { From 047496158f6b311cc3cdbc41c5d775e5834f2f45 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 12:58:09 -0700 Subject: [PATCH 10/50] Handle negative audit exit codes Pass the workload exit code using Clap's equals syntax so timeout and error values such as -1 reach plm stop without being parsed as options. Add regression coverage for positive and negative exit codes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- src/core/wxc/src/main.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index bd1952f95..d4cb9b95b 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -543,8 +543,7 @@ fn audit_stop_args( args.push(std::ffi::OsString::from("--config-path")); args.push(config_path.as_os_str().to_owned()); } - args.push(std::ffi::OsString::from("--exit-code")); - args.push(std::ffi::OsString::from(exit_code.to_string())); + args.push(std::ffi::OsString::from(format!("--exit-code={exit_code}"))); args } @@ -1507,14 +1506,18 @@ mod tests { let args = audit_stop_args(Some(std::path::Path::new(r"C:\config.json")), 23); assert_eq!( args, - [ - "stop", - "--config-path", - r"C:\config.json", - "--exit-code", - "23" - ] - .map(std::ffi::OsString::from) + ["stop", "--config-path", r"C:\config.json", "--exit-code=23"] + .map(std::ffi::OsString::from) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn audit_stop_args_accept_negative_workload_exit_code() { + let args = audit_stop_args(None, -1); + assert_eq!( + args, + ["stop", "--exit-code=-1"].map(std::ffi::OsString::from) ); } From 041948fd708588df230adf35c0a6fa827d06b338 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 10 Aug 2026 18:09:16 -0700 Subject: [PATCH 11/50] Restrict PLM elevation boundary Run the public PLM process under the caller token and elevate only authenticated fixed WPR control operations. Transfer ETL data over bounded local pipes, use protected ProgramData scratch storage, and retain a guarded elevated start child for owner-death cleanup and explicit post-stop disarm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e48fee-23e1-4b05-803c-522812fcdda2 --- README.md | 2 +- src/Cargo.lock | 2 +- src/core/wxc/Cargo.toml | 9 +- src/core/wxc/src/audit.rs | 498 ++++++----- src/core/wxc/src/main.rs | 150 +--- src/core/wxc/src/plm_launch.rs | 260 ------ src/host/plm/Cargo.toml | 16 +- src/host/plm/build.rs | 46 +- src/host/plm/readme.md | 47 +- src/host/plm/src/coordination.rs | 218 +---- src/host/plm/src/elevated.rs | 1150 +++++++++++++++++++++++++ src/host/plm/src/elevated_protocol.rs | 146 ++++ src/host/plm/src/lib.rs | 9 + src/host/plm/src/log.rs | 20 +- src/host/plm/src/main.rs | 512 +++-------- src/host/plm/src/parent_auth.rs | 223 +++++ src/host/plm/src/profile_gen.rs | 124 +-- src/host/plm/src/secure_scratch.rs | 618 +++++++++++++ src/host/plm/src/start.rs | 157 ++-- src/host/plm/src/stop.rs | 25 +- src/host/plm/src/wpr_path.rs | 4 +- 21 files changed, 2734 insertions(+), 1502 deletions(-) delete mode 100644 src/core/wxc/src/plm_launch.rs create mode 100644 src/host/plm/src/elevated.rs create mode 100644 src/host/plm/src/elevated_protocol.rs create mode 100644 src/host/plm/src/parent_auth.rs create mode 100644 src/host/plm/src/secure_scratch.rs diff --git a/README.md b/README.md index d80f33e4a..fac455e95 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ See [docs/diagnostics.md](docs/diagnostics.md) for full diagnostics reference. ### Audit Mode (Permissive Learning Mode) -`--audit` runs a Windows **ProcessContainer** policy in permissive mode — denied operations are logged but allowed to proceed — and starts a Permissive Learning Mode (PLM) ETW trace alongside the workload. It is rejected for Windows Sandbox, WSLC, IsolationSession, and every other containment backend because those paths do not honor the AppContainer learning-mode capability. It is the developer inner-loop flow for discovering the capabilities and paths a workload needs. See [src/host/plm/readme.md](src/host/plm/readme.md) for the full PLM tool reference, including standalone `plm.exe` invocation (e.g. re-processing an existing `.etl` with `plm stop --trace-file …`). +`--audit` runs a Windows **ProcessContainer** policy in permissive mode — denied operations are logged but allowed to proceed — and starts a Permissive Learning Mode (PLM) ETW trace alongside the workload. `wxc-exec.exe` and the public `plm.exe` remain unelevated; PLM uses UAC only for fixed WPR start/stop/cancel controls, no service is added, and ETL/status handoff stays on a unique authenticated local named pipe while every caller-selected log/config/trace destination and ETL analysis stay under the caller token. It is rejected for Windows Sandbox, WSLC, IsolationSession, and every other containment backend because those paths do not honor the AppContainer learning-mode capability. It is the developer inner-loop flow for discovering the capabilities and paths a workload needs. See [src/host/plm/readme.md](src/host/plm/readme.md) for the full PLM tool reference, including standalone `plm.exe` invocation (e.g. re-processing an existing `.etl` with `plm stop --trace-file …`). ```bash wxc-exec.exe --audit policy.json diff --git a/src/Cargo.lock b/src/Cargo.lock index b63188690..b82f7f25a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1736,7 +1736,7 @@ dependencies = [ "anyhow", "chrono", "clap", - "embed-manifest", + "getrandom 0.2.17", "learning_mode_core", "learning_mode_windows", "mxc_build_common", diff --git a/src/core/wxc/Cargo.toml b/src/core/wxc/Cargo.toml index 93e64b203..205308829 100644 --- a/src/core/wxc/Cargo.toml +++ b/src/core/wxc/Cargo.toml @@ -23,12 +23,9 @@ hyperlight_common = { workspace = true, optional = true } appcontainer_common = { workspace = true } nanvix_binaries = { path = "../../backends/nanvix/binaries", optional = true } wslc_common = { workspace = true, optional = true } -# Shared PLM dep: `wpr_command` (safe wpr.exe resolver used for -# ctrl-c cleanup), `CTRL_HANDLER_DRAIN_TIMEOUT`, `wait_until_cleared`, -# and the `singleton::*` primitives — single source of truth so the -# two crates can't drift. The `--wxc-singleton-held-by-parent` handoff -# is a CLI arg on `plm.exe`, not an env var, because ShellExecuteExW -# with `runas` drops the caller's env block on the way through UAC. +# Shared PLM dep: coordination constants, singleton primitives, and the +# mutually authenticated unelevated parent handoff. WPR control stays behind +# PLM's restricted elevated child. plm = { path = "../../host/plm" } tempfile.workspace = true diff --git a/src/core/wxc/src/audit.rs b/src/core/wxc/src/audit.rs index 2e93e85cf..82421e1ee 100644 --- a/src/core/wxc/src/audit.rs +++ b/src/core/wxc/src/audit.rs @@ -1,253 +1,245 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Graceful-exit PLM audit-trace lifecycle for `wxc-exec --audit`. -//! -//! **Invariant: `wxc-exec.exe` runs unelevated.** Starting a WPR -//! kernel ETW session requires administrator, so `--audit` does NOT -//! self-elevate `wxc-exec`; instead it delegates the privileged work -//! to `plm.exe`, which carries a `requireAdministrator` manifest and -//! is spawned via `ShellExecuteExW` + `runas` (UAC). See -//! [`crate::plm_launch::run_plm_elevated`] for the spawn wrapper. -//! Every `run_plm_command(...)` call in this module therefore -//! triggers a UAC prompt when invoked from a medium-IL shell — one -//! prompt per `plm start`, one per `plm stop`. -//! -//! `--audit` runs `plm.exe start`, which leaves a live WPR ETW session -//! in the kernel for the duration of the workload. The matching -//! `plm.exe stop` tears it down. If anything between those two calls -//! aborts wxc-exec — Ctrl-C, panic, `process::exit`, container-runner -//! kill — the kernel session stays allocated until reboot or manual -//! `wpr -cancel`, blocking all other WPR consumers on the host (only -//! one NT Kernel Logger session can exist at a time). -//! -//! We bracket the live-trace window with `AUDIT_ACTIVE` plus a stack- -//! owned `AuditTraceGuard`. Cleanup paths: -//! * Normal exit and panic unwind — `AuditTraceGuard::drop` invokes -//! `cancel_active_audit_trace()`. -//! * Ctrl-C / Ctrl-Break / console close — the `dacl_ctrl_handler` -//! (in `main.rs`) also calls `cancel_active_audit_trace()` after -//! handling DACLs. -//! -//! `cancel_active_audit_trace()` is idempotent via the AtomicBool, so -//! it is safe for both paths to call it. -//! -//! The host-wide named-mutex singleton (`Global\Mxc_Plm_Audit`) is -//! shared with `plm.exe`; both binaries acquire and release it via -//! `plm::coordination::singleton` so their retry-on-conflict paths can -//! never silently `wpr -cancel` a peer trace. - -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; - -use wxc_common::logger::Logger; - -/// Path to `plm.exe`, expected to sit next to `wxc-exec.exe` in the -/// same install directory. Returns `None` when the current exe path -/// can't be resolved. -pub fn plm_exe_path() -> Option { - std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.join("plm.exe"))) -} - -/// Run `plm.exe ` synchronously via -/// `run_plm_elevated`, which captures the child's stdout/stderr into -/// temp files (the UAC broker can't inherit our stdio) and replays -/// them only on non-zero exit or when `verbose` is set — the happy -/// path is deliberately silent. Audit tracing is a best-effort -/// diagnostic: missing-binary / spawn / non-zero-exit conditions are -/// logged and returned as `false` — this function never calls -/// `process::exit` on its own. The caller (currently the `--audit` -/// entry point) is responsible for deciding whether a `false` return -/// should abort the workload; today the `plm start` caller does abort -/// rather than run --audit without an active trace, while `plm stop` -/// merely falls through to the `wpr -cancel` cleanup path. -/// -/// Returns `true` iff the spawn succeeded **and** plm.exe exited with -/// a zero status. The caller needs this signal to decide whether to -/// clear `AUDIT_ACTIVE` (only after a successful `plm stop`); without -/// it, `AUDIT_ACTIVE.store(false)` would run unconditionally and -/// silently leak the kernel ETW session on every failure path. -pub fn run_plm_command(args: &[&std::ffi::OsStr], logger: &mut Logger, verbose: bool) -> bool { - use std::fmt::Write as _; - - let Some(plm) = plm_exe_path() else { - let _ = writeln!(logger, "[audit] could not resolve plm.exe path"); - return false; - }; - if !plm.exists() { - let _ = writeln!( - logger, - "[audit] plm.exe not found at {} - skipping", - plm.display() - ); - return false; - } - - let mut summary = String::new(); - let _ = write!(summary, "[audit] running {}", plm.display()); - for a in args { - let _ = write!(summary, " {}", a.to_string_lossy()); - } - let _ = writeln!(logger, "{summary}"); - if verbose { - eprintln!("{summary}"); - } - - // plm.exe normally acquires the `Global\Mxc_Plm_Audit` named- - // mutex singleton on direct operator invocations (`plm log` / - // `plm start` / `plm stop`) so its retry-on-conflict path can't - // silently `wpr -cancel` a peer trace. When wxc-exec spawns - // plm.exe we already hold that mutex for the whole audit window - // — tell the child to skip its own acquisition so we don't - // deadlock on the same global name. The signal used to be an env - // var (SINGLETON_HELD_BY_PARENT_ENV) but `ShellExecuteExW` + - // `runas` (used by run_plm_elevated) does not propagate the - // caller's environment across the elevation boundary, so it now - // rides on a hidden CLI flag. - match crate::plm_launch::run_plm_elevated(&plm, args, true) { - Ok(run) if run.exit_code == 0 => { - if verbose { - replay_captured(logger, &run.stdout, &run.stderr); - } - true - } - Ok(run) => { - let _ = writeln!(logger, "[audit] plm exited with code {}", run.exit_code); - replay_captured(logger, &run.stdout, &run.stderr); - if verbose { - eprintln!("[audit] plm exited with code {}", run.exit_code); - } - false - } - Err(msg) => { - let _ = writeln!(logger, "[audit] failed to launch elevated plm: {msg}"); - if verbose { - eprintln!("[audit] failed to launch elevated plm: {msg}"); - } - false - } - } -} - -/// Replay captured stdout/stderr bytes to the current process's own -/// streams. Used on failure (and in verbose mode on success) so the -/// happy path can stay silent while diagnostics still surface. Byte -/// slices come from `ShellExecuteExW`-elevated child capture, which -/// cannot go through OS pipe inheritance and is redirected to temp -/// files at the plm.exe end (see `plm_launch::run_plm_elevated`). -fn replay_captured(logger: &mut Logger, stdout: &[u8], stderr: &[u8]) { - use std::fmt::Write as _; - use std::io::Write as _; - if !stdout.is_empty() { - let _ = std::io::stdout().write_all(stdout); - let _ = write!(logger, "{}", String::from_utf8_lossy(stdout)); - } - if !stderr.is_empty() { - let _ = std::io::stderr().write_all(stderr); - let _ = write!(logger, "{}", String::from_utf8_lossy(stderr)); - } -} - -pub static AUDIT_ACTIVE: AtomicBool = AtomicBool::new(false); - -/// Set to `true` while `plm start` is being spawned and has not yet -/// returned. `AUDIT_ACTIVE` is flipped to `true` BEFORE `plm.exe` is -/// spawned (because `mark_audit_active()` has to run early to cover a -/// Ctrl+C arriving mid-spawn), but the kernel ETW session is not -/// actually engaged until `plm.exe`'s child `wpr -start` returns. A -/// Ctrl+C in that gap would fire `wpr -cancel` against a not-yet- -/// existing session, then `wpr -start` would silently succeed AFTER -/// the cancel — leaking the session past `wxc-exec`'s own cleanup. We -/// close the race by making the Ctrl+C handler wait (bounded) until -/// `plm start` has finished its spawn round-trip before deciding -/// whether to issue the cancel. -pub static AUDIT_START_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -/// Mark that the wxc-exec process owns a live PLM audit trace. Called -/// just before `plm start` is spawned so a Ctrl-C arriving mid-spawn -/// still triggers cleanup (over-cancelling a not-yet-started session -/// is harmless — `wpr -cancel` returns non-zero and we discard). -pub fn mark_audit_active() { - AUDIT_ACTIVE.store(true, Ordering::SeqCst); -} - -/// Cancel an in-flight PLM audit trace iff one is active, then clear -/// the flag. Idempotent; safe to call from the Ctrl-C handler and the -/// stack guard's Drop. Failures (no active session, missing wpr.exe) -/// are silenced because the call is best-effort cleanup. -/// -/// Invokes `wpr.exe` by absolute path (`%SystemRoot%\System32\wpr.exe`) -/// rather than as a bare name so `CreateProcessW`'s implicit CWD-first -/// search order can't be abused to substitute a planted binary. -/// `wxc-exec` itself runs unelevated; the privileged `wpr -start` / -/// `wpr -stop` calls are delegated to the elevated `plm.exe` child -/// (see [`crate::plm_launch::run_plm_elevated`]). We still resolve -/// wpr by absolute path here for the best-effort panic / ctrl-c -/// cleanup so behavior is consistent with the plm.exe side, which -/// applies the same hardening in its own resolver. -pub fn cancel_active_audit_trace() { - if AUDIT_ACTIVE.swap(false, Ordering::SeqCst) { - let _ = plm::wpr_path::wpr_command() - .arg("-cancel") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } -} - -/// Stack-owned guard: ensures the audit trace is cancelled on panic -/// unwind and on normal function return. -pub struct AuditTraceGuard; - -impl Drop for AuditTraceGuard { - fn drop(&mut self) { - cancel_active_audit_trace(); - } -} - -/// Raw handle of the host-wide single-instance mutex for PLM audit -/// mode. Two concurrent `wxc-exec --audit` runs would share a single -/// NT Kernel Logger session, so the second one's `wpr -start` would -/// either steal the first's session or fail and silently corrupt the -/// first run's findings. `wxc-exec` (unelevated) acquires the named -/// mutex (`Global\\` so it's machine-wide across sessions) and -/// refuses to start if another wxc-exec audit is already running. -/// The elevated `plm.exe` child skips its own acquisition of the -/// same mutex via the `--wxc-singleton-held-by-parent` flag so the -/// parent's handle remains the sole owner for the trace lifetime. -/// -/// The handle is stashed in a static atomic (not just the stack guard) -/// so the explicit cleanup before `process::exit` — which skips -/// destructors — can release it too. `AuditSingletonGuard::drop` is -/// a thin shim over `release_audit_singleton`; both paths are -/// idempotent. -static AUDIT_SINGLETON_HANDLE: AtomicIsize = AtomicIsize::new(0); - -pub struct AuditSingletonGuard; - -impl Drop for AuditSingletonGuard { - fn drop(&mut self) { - release_audit_singleton(); - } -} - -/// Release the host-wide audit singleton if held. Idempotent: safe to -/// call from `Drop`, from the explicit pre-`process::exit` cleanup, -/// and from error paths. -pub fn release_audit_singleton() { - plm::coordination::singleton::release(&AUDIT_SINGLETON_HANDLE); -} - -pub fn try_acquire_audit_singleton() -> Result { - use plm::coordination::singleton::{try_acquire, AcquireError}; - match try_acquire(&AUDIT_SINGLETON_HANDLE) { - Ok(()) => Ok(AuditSingletonGuard), - Err(AcquireError::AlreadyHeld) => Err(String::from( - "another wxc-exec --audit run holds the Global\\Mxc_Plm_Audit mutex; \ - refusing to start a second concurrent PLM trace (only one NT Kernel \ - Logger session can exist per host)", - )), - Err(AcquireError::CreateFailed(e)) => Err(format!("CreateMutexW failed: {e}")), - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Graceful-exit PLM audit-trace lifecycle for `wxc-exec --audit`. +//! +//! **Invariant: `wxc-exec.exe` runs unelevated.** Starting a WPR +//! kernel ETW session requires administrator, so `--audit` does NOT +//! self-elevate `wxc-exec`; instead it launches PLM's hidden, fixed-operation +//! START child through UAC and retains its authenticated control pipe. +//! +//! The child opens and validates the wxc-exec owner before starting WPR, stays +//! alive through the workload, and cancels on owner death or pipe break. +//! Successful stop explicitly sends DISARM and waits for that child to exit. +//! +//! The host-wide named-mutex singleton (`Global\Mxc_Plm_Audit`) is +//! shared with `plm.exe`; both binaries acquire and release it via +//! `plm::coordination::singleton` so their retry-on-conflict paths can +//! never silently `wpr -cancel` a peer trace. + +use std::sync::atomic::AtomicIsize; + +use wxc_common::logger::Logger; + +/// Path to `plm.exe`, expected to sit next to `wxc-exec.exe` in the +/// same install directory. Returns `None` when the current exe path +/// can't be resolved. +pub fn plm_exe_path() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("plm.exe"))) +} + +/// Run the public `plm.exe ` synchronously under the +/// current token, capturing stdout/stderr and replaying them only on non-zero +/// exit or when `verbose` is set. Audit tracing is a best-effort +/// diagnostic: missing-binary / spawn / non-zero-exit conditions are +/// logged and returned as `false` — this function never calls +/// `process::exit` on its own. The caller (currently the `--audit` +/// entry point) is responsible for deciding whether a `false` return should +/// abort the workload. +/// +/// Returns `true` iff the spawn succeeded **and** plm.exe exited with +/// a zero status. +pub fn run_plm_command(args: &[&std::ffi::OsStr], logger: &mut Logger, verbose: bool) -> bool { + use std::fmt::Write as _; + + let Some(plm) = plm_exe_path() else { + let _ = writeln!(logger, "[audit] could not resolve plm.exe path"); + return false; + }; + if !plm.exists() { + let _ = writeln!( + logger, + "[audit] plm.exe not found at {} - skipping", + plm.display() + ); + return false; + } + + let mut summary = String::new(); + let _ = write!(summary, "[audit] running {}", plm.display()); + for a in args { + let _ = write!(summary, " {}", a.to_string_lossy()); + } + let _ = writeln!(logger, "{summary}"); + if verbose { + eprintln!("{summary}"); + } + + // Public plm.exe normally acquires the `Global\Mxc_Plm_Audit` named- + // mutex singleton on direct operator invocations (`plm log` / + // `plm start` / `plm stop`) so its retry-on-conflict path can't + // silently `wpr -cancel` a peer trace. When wxc-exec spawns + // plm.exe we already hold that mutex for the whole audit window + // — tell the child to skip its own acquisition so we don't + // deadlock on the same global name. The bypass is a one-shot local named + // pipe whose server/client PIDs are checked in both directions; there is + // no spoofable environment variable or bare hidden flag. + let output = run_authorized_public_plm(&plm, args); + match output { + Ok(run) if run.status.success() => { + if verbose { + replay_captured(logger, &run.stdout, &run.stderr); + } + true + } + Ok(run) => { + let code = run.status.code().unwrap_or(-1); + let _ = writeln!(logger, "[audit] plm exited with code {code}"); + replay_captured(logger, &run.stdout, &run.stderr); + if verbose { + eprintln!("[audit] plm exited with code {code}"); + } + false + } + Err(error) => { + let _ = writeln!(logger, "[audit] failed to launch plm: {error}"); + if verbose { + eprintln!("[audit] failed to launch plm: {error}"); + } + false + } + } +} + +/// Replay captured stdout/stderr bytes to the current process's own +/// streams. Used on failure (and in verbose mode on success) so the happy path +/// can stay silent while diagnostics still surface. +fn replay_captured(logger: &mut Logger, stdout: &[u8], stderr: &[u8]) { + use std::fmt::Write as _; + use std::io::Write as _; + if !stdout.is_empty() { + let _ = std::io::stdout().write_all(stdout); + let _ = write!(logger, "{}", String::from_utf8_lossy(stdout)); + } + if !stderr.is_empty() { + let _ = std::io::stderr().write_all(stderr); + let _ = write!(logger, "{}", String::from_utf8_lossy(stderr)); + } +} + +fn run_authorized_public_plm( + plm_path: &std::path::Path, + args: &[&std::ffi::OsStr], +) -> Result { + let authorization = plm::parent_auth::ParentAuthorization::new()?; + let mut child = std::process::Command::new(plm_path) + .arg("--wxc-parent-auth") + .arg(authorization.pipe_name()) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to spawn public plm.exe: {error}"))?; + if let Err(error) = authorization.authorize(child.id()) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + child + .wait_with_output() + .map_err(|error| format!("failed to wait for public plm.exe: {error}")) +} + +/// Stack-owned guarded START session. +#[derive(Debug)] +pub struct AuditTraceGuard { + session: plm::elevated::GuardedSession, +} + +impl AuditTraceGuard { + pub fn start(logger: &mut Logger, verbose: bool) -> Result { + use std::fmt::Write as _; + + let plm = plm_exe_path().ok_or_else(|| { + let message = "could not resolve plm.exe path".to_string(); + let _ = writeln!(logger, "[audit] {message}"); + message + })?; + if !plm.exists() { + let message = format!("plm.exe not found at {}", plm.display()); + let _ = writeln!(logger, "[audit] {message}"); + return Err(message); + } + + let summary = format!("[audit] running {} start", plm.display()); + let _ = writeln!(logger, "{summary}"); + if verbose { + eprintln!("{summary}"); + } + let owner_pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + plm::elevated::start_guarded_session_with_executable(&plm, owner_pid) + .map(|session| Self { session }) + .map_err(|error| { + let message = format!("guarded plm start failed: {error:#}"); + let _ = writeln!(logger, "[audit] {message}"); + if verbose { + eprintln!("[audit] {message}"); + } + message + }) + } + + pub fn disarm(&mut self, logger: &mut Logger, verbose: bool) -> bool { + use std::fmt::Write as _; + + match self.session.disarm() { + Ok(()) => true, + Err(error) => { + let _ = writeln!(logger, "[audit] failed to disarm guarded start: {error:#}"); + if verbose { + eprintln!("[audit] failed to disarm guarded start: {error:#}"); + } + false + } + } + } +} + +/// Raw handle of the host-wide single-instance mutex for PLM audit +/// mode. Two concurrent `wxc-exec --audit` runs would share a single +/// NT Kernel Logger session, so the second one's `wpr -start` would +/// either steal the first's session or fail and silently corrupt the +/// first run's findings. `wxc-exec` (unelevated) acquires the named +/// mutex (`Global\\` so it's machine-wide across sessions) and +/// refuses to start if another wxc-exec audit is already running. +/// Each public `plm.exe` child skips its own acquisition only after a +/// mutually authenticated one-shot named-pipe handshake with this direct +/// parent, so the parent's handle remains the sole owner for the trace +/// lifetime without exposing a spoofable bypass flag. +/// +/// The handle is stashed in a static atomic (not just the stack guard) +/// so the explicit cleanup before `process::exit` — which skips +/// destructors — can release it too. `AuditSingletonGuard::drop` is +/// a thin shim over `release_audit_singleton`; both paths are +/// idempotent. +static AUDIT_SINGLETON_HANDLE: AtomicIsize = AtomicIsize::new(0); + +pub struct AuditSingletonGuard; + +impl Drop for AuditSingletonGuard { + fn drop(&mut self) { + release_audit_singleton(); + } +} + +/// Release the host-wide audit singleton if held. Idempotent: safe to +/// call from `Drop`, from the explicit pre-`process::exit` cleanup, +/// and from error paths. +pub fn release_audit_singleton() { + plm::coordination::singleton::release(&AUDIT_SINGLETON_HANDLE); +} + +pub fn try_acquire_audit_singleton() -> Result { + use plm::coordination::singleton::{try_acquire, AcquireError}; + match try_acquire(&AUDIT_SINGLETON_HANDLE) { + Ok(()) => Ok(AuditSingletonGuard), + Err(AcquireError::AlreadyHeld) => Err(String::from( + "another wxc-exec --audit run holds the Global\\Mxc_Plm_Audit mutex; \ + refusing to start a second concurrent PLM trace (only one NT Kernel \ + Logger session can exist per host)", + )), + Err(AcquireError::CreateFailed(e)) => Err(format!("CreateMutexW failed: {e}")), + } +} diff --git a/src/core/wxc/src/main.rs b/src/core/wxc/src/main.rs index d4cb9b95b..746933413 100644 --- a/src/core/wxc/src/main.rs +++ b/src/core/wxc/src/main.rs @@ -4,12 +4,9 @@ #[cfg(target_os = "windows")] mod audit; #[cfg(target_os = "windows")] -mod plm_launch; - use std::fmt::Write; use std::fs; use std::process; -use std::sync::atomic::Ordering; use std::sync::{Mutex, OnceLock}; use std::time::Instant; @@ -549,9 +546,8 @@ fn audit_stop_args( #[cfg(target_os = "windows")] use audit::{ - cancel_active_audit_trace, mark_audit_active, release_audit_singleton, run_plm_command, - try_acquire_audit_singleton, AuditSingletonGuard, AuditTraceGuard, AUDIT_ACTIVE, - AUDIT_START_IN_FLIGHT, + release_audit_singleton, run_plm_command, try_acquire_audit_singleton, AuditSingletonGuard, + AuditTraceGuard, }; // --------------------------------------------------------------------------- @@ -666,19 +662,13 @@ unsafe extern "system" fn dacl_ctrl_handler(_ctrl_type: u32) -> windows::core::B // Emit first. No-op unless telemetry is active; emits no message text // and does not shut the provider down (the OS reclaims it at exit). telemetry::emit_cancellation, - // Then the security-critical cleanup: restore host DACLs and cancel any - // in-flight audit trace. + // Then the security-critical host DACL cleanup. || { if let Some(slot) = DACL_CLEANUP_SLOT.get() { use std::time::{Duration, Instant}; - // The handler runs TWO bounded waits (this one + the - // AUDIT_START_IN_FLIGHT wait below) before `wpr -cancel`, and // CTRL_CLOSE_EVENT / CTRL_LOGOFF / CTRL_SHUTDOWN have a hard - // ~5s OS-imposed kill budget. The per-wait budget is sourced - // from the shared `plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT` - // so `wxc-exec` and `plm.exe`'s `plm_ctrl_handler` cannot - // drift apart, and the budget invariant is pinned by a unit - // test (`ctrl_handler_drain_timeout_respects_os_budget`). + // ~5s OS-imposed kill budget. The persistent elevated PLM + // child handles trace cleanup after this owner terminates. let deadline = Instant::now() + plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT; loop { if let Ok(mut guard) = slot.try_lock() { @@ -696,28 +686,6 @@ unsafe extern "system" fn dacl_ctrl_handler(_ctrl_type: u32) -> windows::core::B std::thread::sleep(Duration::from_millis(50)); } } - // if `plm start` is still in flight when Ctrl+C arrives, wait - // briefly for it to complete before deciding whether to issue - // `wpr -cancel`. Without this wait, a cancel that races a - // not-yet-engaged session is a no-op and the session leaks past - // wxc-exec exit. On timeout we proceed anyway — the next-startup - // `recover_orphaned_state` scan plus a manual `wpr -cancel` would - // catch any residue. - // - // The wait loop is implemented by - // `plm::coordination::wait_until_cleared`, the same tested helper - // `plm.exe`'s console-control handler uses. The per-wait timeout is - // sourced from the shared - // `plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT` const so the - // wxc-exec and plm.exe handlers cannot drift apart. The const's - // docs (and the `ctrl_handler_drain_timeout_respects_os_budget` - // unit test) pin the ~5s OS kill-budget invariant. - let _ = plm::coordination::wait_until_cleared( - &AUDIT_START_IN_FLIGHT, - plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT, - std::time::Duration::from_millis(50), - ); - cancel_active_audit_trace(); }, ); // FALSE = "I did not fully handle this; run the next handler in the @@ -1229,82 +1197,39 @@ fn main() { // the runner spawns the container so we capture access-denied events // for the lifetime of the workload. The matching `plm stop` below // tears the trace down and (when the policy came from a file) - // merges findings back into it. Both calls are best-effort. - // - // Bracket the live-trace window with `AUDIT_ACTIVE` + a stack guard - // so Ctrl-C / panic / process::exit between start and stop don't - // leak the kernel ETW session. + // merges findings back into it. // - // declaration order matters. Rust - // drops locals in REVERSE declaration order, and on the cleanup - // path we want the trace guard (`AuditTraceGuard`, which calls - // `wpr -cancel`) to run BEFORE the singleton handle is released — - // otherwise a concurrent wxc-exec could acquire the freed mutex - // and start its own trace, only to have our stale `wpr -cancel` - // tear it down. Declare the singleton first so it drops last. + // Declaration order matters. The guarded START connection must close + // (and its elevated child finish cleanup) before the singleton releases, + // so declare the singleton first and the trace guard second. #[cfg(target_os = "windows")] let _audit_singleton: Option; #[cfg(target_os = "windows")] - let _audit_guard: Option; + let mut audit_guard: Option; #[cfg(target_os = "windows")] let audit_config_file = if cli.audit { - // refuse to start a second concurrent - // audit. We acquire the host-wide named mutex BEFORE marking - // AUDIT_ACTIVE so a failure here doesn't engage the cleanup - // path that would cancel someone else's running trace. match try_acquire_audit_singleton() { Ok(g) => _audit_singleton = Some(g), Err(msg) => { let _ = writeln!(logger, "[audit] {msg}"); eprintln!("error: {msg}"); - _audit_singleton = None; - _audit_guard = None; std::process::exit(1); } } - mark_audit_active(); - _audit_guard = Some(AuditTraceGuard); - // Bail explicitly on `plm start` failure rather than - // discarding the failure status. If plm start failed - // (missing plm.exe, wpr session conflict not resolved, - // etc.), the workload would run with `permissiveLearningMode` - // injected into the sandbox policy but with zero WPR - // recording — an empty Adjusted_*.json looks like "no - // denials." Bailing lets the operator see the error and the - // policy isn't silently relaxed. - // - // Bracket the spawn with - // AUDIT_START_IN_FLIGHT so the console-control handler waits - // for it to drain before deciding whether to issue `wpr - // -cancel` (closes the Ctrl+C race where cancel arrives - // before `plm.exe`'s child `wpr -start` has engaged the - // kernel session). - AUDIT_START_IN_FLIGHT.store(true, Ordering::SeqCst); - let start_ok = run_plm_command( - &[std::ffi::OsStr::new("start")], - &mut logger, - cli.audit_verbose, - ); - AUDIT_START_IN_FLIGHT.store(false, Ordering::SeqCst); - if !start_ok { - let _ = writeln!( - logger, - "[audit] plm start failed; refusing to run the workload with \ - permissiveLearningMode but no WPR recording" - ); - eprintln!( - "error: plm start failed; refusing to run --audit without an \ - active trace. See logs for details." - ); - // cancel_active_audit_trace is idempotent and safe to call - // even if start never began a session — it inspects the - // AUDIT_ACTIVE flag and only invokes wpr -cancel if set. - cancel_active_audit_trace(); - std::process::exit(1); + match AuditTraceGuard::start(&mut logger, cli.audit_verbose) { + Ok(guard) => audit_guard = Some(guard), + Err(_) => { + eprintln!( + "error: plm start failed; refusing to run --audit without an \ + active trace. See logs for details." + ); + release_audit_singleton(); + std::process::exit(1); + } } config_file_path(&cli) } else { - _audit_guard = None; + audit_guard = None; _audit_singleton = None; None }; @@ -1317,14 +1242,6 @@ fn main() { // Tear down the PLM trace after the container exits, regardless of // its exit code. Done before the runner is dropped so the trace // tooling sees a fully-quiesced workload. - // - // Only clear `AUDIT_ACTIVE` when `plm stop` actually - // succeeded. Clearing it unconditionally would silently leak - // the kernel ETW session whenever stop failed (missing - // plm.exe, spawn fail, wpr -stop non-zero) and simultaneously - // turn `AuditTraceGuard::drop` and the Ctrl-C handler into - // no-ops. On failure, leave the flag set so the stack guard's - // `Drop` runs `wpr -cancel` for us. #[cfg(target_os = "windows")] if cli.audit { let stop_args = audit_stop_args(audit_config_file.as_deref(), response.exit_code); @@ -1334,12 +1251,18 @@ fn main() { .collect(); let stop_ok = run_plm_command(&borrowed, &mut logger, cli.audit_verbose); if stop_ok { - AUDIT_ACTIVE.store(false, Ordering::SeqCst); + if let Some(guard) = audit_guard.as_mut() { + if !guard.disarm(&mut logger, cli.audit_verbose) { + let _ = writeln!( + logger, + "[audit] stop succeeded but DISARM failed; guarded cleanup remains armed" + ); + } + } } else { let _ = writeln!( logger, - "[audit] plm stop failed; leaving AUDIT_ACTIVE set so cleanup guards \ - will run wpr -cancel on exit" + "[audit] plm stop failed; guarded cleanup remains armed" ); } } @@ -1354,17 +1277,12 @@ fn main() { drop(runner); drop(take_parked_dacl()); - // the `process::exit` below skips destructors, so - // `AuditTraceGuard::drop` (which calls `cancel_active_audit_trace`) - // and `AuditSingletonGuard::drop` (which releases the host-wide - // named mutex) never run on the normal path. Leaving `AUDIT_ACTIVE` - // set so cleanup guards run `wpr -cancel` on stop failure is only - // true on the panic-unwind / Ctrl-C path, not here. Manually - // invoke the cleanups so a stop-failure path actually tears the - // kernel ETW session down and frees the singleton. + // `process::exit` below skips destructors. Drop the guarded connection + // first so it either confirms DISARM or cancels while the singleton still + // excludes a new WPR owner, then release the singleton. #[cfg(target_os = "windows")] { - cancel_active_audit_trace(); + drop(audit_guard); release_audit_singleton(); } diff --git a/src/core/wxc/src/plm_launch.rs b/src/core/wxc/src/plm_launch.rs deleted file mode 100644 index be4a8692c..000000000 --- a/src/core/wxc/src/plm_launch.rs +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Elevated spawn of `plm.exe` from unelevated `wxc-exec --audit`. -//! -//! **Invariant: `wxc-exec.exe` itself never runs elevated.** It ships -//! without a `requireAdministrator` manifest and is expected to be -//! launched by an unelevated (medium-integrity) user. Any operation -//! that legitimately needs admin — currently only the PLM audit-trace -//! WPR/ETW session — is delegated to a dedicated admin-manifested -//! helper binary (`plm.exe`), which we spawn via UAC and wait on. -//! This matches the pattern used by `wxc-host-prep.exe` for the -//! system-drive / null-device ACL work. -//! -//! `plm.exe` carries a `requireAdministrator` application manifest -//! (see `src/host/plm/build.rs`). A standard `std::process::Command` -//! spawn from an unelevated parent fails with -//! `ERROR_ELEVATION_REQUIRED` (740) — the OS refuses to load such a -//! binary except via a UAC-mediated launch. This module wraps that -//! launch using `ShellExecuteExW` with the `runas` verb, waits for -//! the elevated child to exit, and surfaces its exit code + any -//! diagnostic output the child captured to files in a random-suffix -//! temp directory whose path is passed on the command line (the -//! elevation broker does not propagate env vars or stdio handles -//! across the elevation boundary, so both signals must ride on -//! `lpParameters`). -//! -//! Windows-only. - -use std::env; -use std::os::windows::ffi::OsStrExt; -use std::path::{Path, PathBuf}; - -use windows::core::PCWSTR; -use windows::Win32::Foundation::{CloseHandle, ERROR_CANCELLED, HANDLE, WAIT_FAILED, WAIT_TIMEOUT}; -use windows::Win32::System::Threading::{ - GetExitCodeProcess, TerminateProcess, WaitForSingleObject, -}; -use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; - -/// `SW_SHOWNORMAL` from `Win32_UI_WindowsAndMessaging`. Copied as a -/// literal to avoid pulling in that whole feature for one constant. -const SW_SHOWNORMAL: i32 = 1; - -/// Maximum time to wait for an elevated `plm.exe` child to exit before -/// giving up and returning an error. -/// -/// `plm stop` parses the captured ETL trace inline, which its own -/// progress message ("Beginning event parsing, this may take several -/// minutes") flags as a multi-minute operation on a large trace. The -/// bound therefore has to comfortably exceed that parse cost while still -/// avoiding an unbounded hang if the child wedges (e.g. waiting on a -/// stuck WPR kernel session). Ten minutes leaves ample headroom for the -/// supported "single end-to-end flow / unit test at a time" workload; a -/// child still alive after that is treated as wedged and terminated. -const PLM_ELEVATED_WAIT_MS: u32 = 600_000; - -/// Result of an elevated `plm.exe` invocation. -pub struct ElevatedRun { - /// Exit code of the elevated child. - pub exit_code: i32, - /// Captured stdout, empty if the caller didn't request capture. - pub stdout: Vec, - /// Captured stderr, empty if the caller didn't request capture. - pub stderr: Vec, -} - -fn to_wide>(s: S) -> Vec { - s.as_ref().encode_wide().chain(std::iter::once(0)).collect() -} - -/// Quote a single argument using CommandLineToArgvW rules. -fn quote_arg(arg: &str) -> String { - if !arg.is_empty() && !arg.contains([' ', '\t', '"', '\n']) { - return arg.to_string(); - } - let mut out = String::with_capacity(arg.len() + 2); - out.push('"'); - let chars: Vec = arg.chars().collect(); - let mut i = 0; - while i < chars.len() { - let mut backslashes = 0; - while i < chars.len() && chars[i] == '\\' { - backslashes += 1; - i += 1; - } - if i == chars.len() { - for _ in 0..(backslashes * 2) { - out.push('\\'); - } - } else if chars[i] == '"' { - for _ in 0..(backslashes * 2 + 1) { - out.push('\\'); - } - out.push('"'); - i += 1; - } else { - for _ in 0..backslashes { - out.push('\\'); - } - out.push(chars[i]); - i += 1; - } - } - out.push('"'); - out -} - -/// Invoke `plm_path ` elevated via `ShellExecuteExW` + `runas`, -/// wait for it to exit, and return its exit code + captured stdio. -/// -/// The elevation broker (AppInfo service) creates the elevated child -/// with a fresh environment block and no inheritable stdio handles, -/// so we cannot use env vars to hand off the capture-file paths or -/// the singleton-held bypass. Both signals travel as hidden CLI -/// arguments the child parses out (see `redirect_stdio_from_argv` -/// and the `--wxc-singleton-held-by-parent` handling in plm's -/// `main`). -/// -/// `singleton_held_by_parent = true` tells the elevated child that -/// the caller already holds the `Global\Mxc_Plm_Audit` mutex and it -/// should skip acquisition; without this the child bails with -/// "another PLM trace is already in progress". -pub fn run_plm_elevated( - plm_path: &Path, - args: &[&std::ffi::OsStr], - singleton_held_by_parent: bool, -) -> Result { - // Build a temp directory for the two capture files. Uses a - // random-suffix `tempfile::Builder::tempdir` (not a - // deterministic `%TEMP%\mxc-plm-`) so a same-user medium-IL - // attacker cannot pre-squat the directory or its contents before - // the elevated child (running under the admin token) opens files - // inside it. `tempdir` fails if the directory already exists — - // treat that failure as fatal rather than silently reusing an - // attacker-controlled path. - // - // We deliberately do NOT wrap this in a `TempDir` RAII guard: - // the elevated child writes into the dir long after this - // function's stack frame is gone, and we need the paths to - // survive across the `WaitForSingleObject`. We clean up - // explicitly on every exit path via `remove_dir_all(&tmp_dir)`. - let tmp_dir = match tempfile::Builder::new() - .prefix("mxc-plm-") - .rand_bytes(16) - .tempdir() - { - Ok(td) => td.keep(), - Err(e) => return Err(format!("failed to create plm capture temp dir: {e}")), - }; - let stdout_path = tmp_dir.join("stdout.log"); - let stderr_path = tmp_dir.join("stderr.log"); - // No pre-truncation: the elevated child creates the files - // itself with `create_new(true).append(true)`, which fails with - // `ERROR_FILE_EXISTS` if anything (regular file, symlink, - // junction) already occupies the path. Any parent-side - // pre-write here would defeat that check by materialising the - // target as a plain file first — either turning a legitimate - // capture into `ERROR_FILE_EXISTS`, or (worse) having the - // unelevated parent follow an attacker-planted symlink before - // the elevated child ever runs. - - // Build the parameter string ShellExecuteExW expects. Prepend the - // internal handshake flags before the subcommand args so clap - // parses them as top-level Cli options. `--wxc-capture-dir` and - // `--wxc-singleton-held-by-parent` are hidden `#[arg]`s on Cli. - let mut param_parts: Vec = Vec::with_capacity(args.len() + 3); - param_parts.push("--wxc-capture-dir".to_string()); - param_parts.push(quote_arg(&tmp_dir.to_string_lossy())); - if singleton_held_by_parent { - param_parts.push("--wxc-singleton-held-by-parent".to_string()); - } - for a in args { - param_parts.push(quote_arg(&a.to_string_lossy())); - } - let params_str = param_parts.join(" "); - - let verb_w = to_wide("runas"); - let file_w = to_wide(plm_path.as_os_str()); - let params_w = to_wide(¶ms_str); - let cwd_w = to_wide( - env::current_dir() - .map(PathBuf::into_os_string) - .unwrap_or_default(), - ); - - let mut sei = SHELLEXECUTEINFOW { - cbSize: std::mem::size_of::() as u32, - fMask: SEE_MASK_NOCLOSEPROCESS, - lpVerb: PCWSTR(verb_w.as_ptr()), - lpFile: PCWSTR(file_w.as_ptr()), - lpParameters: PCWSTR(params_w.as_ptr()), - lpDirectory: PCWSTR(cwd_w.as_ptr()), - nShow: SW_SHOWNORMAL, - ..Default::default() - }; - - let result = unsafe { ShellExecuteExW(&mut sei) }; - - if let Err(e) = result { - let raw = (e.code().0 as u32) & 0xFFFF; - if raw == ERROR_CANCELLED.0 { - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err("UAC prompt was cancelled".to_string()); - } - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err(format!("ShellExecuteExW failed: {e}")); - } - - let proc_handle: HANDLE = sei.hProcess; - if proc_handle.is_invalid() { - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err("ShellExecuteExW returned no process handle".to_string()); - } - - let wait = unsafe { WaitForSingleObject(proc_handle, PLM_ELEVATED_WAIT_MS) }; - if wait == WAIT_FAILED { - unsafe { - let _ = CloseHandle(proc_handle); - } - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err("WaitForSingleObject failed on elevated plm child".to_string()); - } - if wait == WAIT_TIMEOUT { - // Kill the orphaned elevated child so it can't finish - // `wpr -start` (or -stop) behind our back and leave the WPR - // kernel logger session alive with nothing tracking it. - // `TerminateProcess` is best-effort; the handle carries - // PROCESS_TERMINATE from `ShellExecuteExW`, but we ignore - // the result either way so cleanup continues. - unsafe { - let _ = TerminateProcess(proc_handle, 1); - let _ = CloseHandle(proc_handle); - } - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err(format!( - "elevated plm child did not exit within {}s", - PLM_ELEVATED_WAIT_MS / 1000 - )); - } - let mut exit_code: u32 = 0; - let rc = unsafe { GetExitCodeProcess(proc_handle, &mut exit_code) }; - unsafe { - let _ = CloseHandle(proc_handle); - } - if rc.is_err() { - let _ = std::fs::remove_dir_all(&tmp_dir); - return Err("GetExitCodeProcess failed on elevated plm child".to_string()); - } - - let stdout = std::fs::read(&stdout_path).unwrap_or_default(); - let stderr = std::fs::read(&stderr_path).unwrap_or_default(); - let _ = std::fs::remove_dir_all(&tmp_dir); - - Ok(ElevatedRun { - exit_code: exit_code as i32, - stdout, - stderr, - }) -} diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index b35dfcece..e35932947 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -10,14 +10,6 @@ path = "src/lib.rs" [[bin]] name = "plm" path = "src/main.rs" -# The release binary carries a `requireAdministrator` application -# manifest (see build.rs) and Windows refuses to launch it unelevated -# with ERROR_ELEVATION_REQUIRED (740). main.rs has no `#[test]` items -# (all unit tests live in the `plm` lib), so its test binary is empty -# — disable it entirely so `cargo test -p plm --release` doesn't try -# to launch the manifested bin from an unelevated dev shell. Library -# and integration tests still run. -test = false [dependencies] clap.workspace = true @@ -29,6 +21,7 @@ serde_json.workspace = true serde.workspace = true chrono.workspace = true tempfile.workspace = true +getrandom.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true, features = [ @@ -36,6 +29,10 @@ windows = { workspace = true, features = [ "Win32_System_EventLog", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_System_Pipes", + "Win32_Storage_FileSystem", + "Win32_UI_Shell", + "Win32_Security", ] } wxc_common = { workspace = true } learning_mode_core = { workspace = true } @@ -44,9 +41,6 @@ learning_mode_windows = { workspace = true } [build-dependencies] mxc_build_common.workspace = true -[target.'cfg(target_os = "windows")'.build-dependencies] -embed-manifest = "1.4" - [dev-dependencies] tempfile.workspace = true quick-xml.workspace = true diff --git a/src/host/plm/build.rs b/src/host/plm/build.rs index ac268317a..e9bc6db02 100644 --- a/src/host/plm/build.rs +++ b/src/host/plm/build.rs @@ -1,50 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Build script for plm — embeds Windows VersionInfo and, on release -//! Windows builds, a `requireAdministrator` application manifest. +//! Build script for the public, `asInvoker` PLM binary. //! -//! `plm.exe` starts / stops NT Kernel Logger ETW sessions via wpr.exe, -//! which requires Administrator. Rather than perform a runtime UAC -//! self-relaunch (which was the design of the reverted 666b08d and -//! violated the "wxc-exec never runs elevated" invariant MGudgin -//! called out in PR#584), we make `plm.exe` itself the elevated -//! helper — analog of `wxc-host-prep.exe`, and hence its new location -//! under `src/host/plm/`. The unelevated `wxc-exec --audit` invokes -//! `plm.exe` via `ShellExecuteExW`+`runas`, the OS shows one UAC -//! prompt at plm's launch, and plm's whole lifetime (including the -//! console-control handler that fires on Ctrl+C) runs elevated. -//! -//! The manifest is only embedded in release builds. Embedding it in -//! debug builds would UAC-gate every `cargo test` / `cargo run` and -//! Windows refuses to launch an unelevated `requireAdministrator` -//! binary at all (error 740) — which would break the unit tests. The -//! runtime `IsUserAnAdmin` posture is unchanged: any privileged code -//! path is still gated in code. +//! PLM elevates only its hidden fixed-operation WPR child at runtime. Do not +//! add a `requireAdministrator` manifest here: parsing ETLs and touching +//! caller-selected output/configuration paths must stay under the caller token. fn main() { mxc_build_common::embed_version_info("MXC permissive learning mode", "plm.exe"); - - #[cfg(target_os = "windows")] - embed_admin_manifest(); -} - -#[cfg(target_os = "windows")] -fn embed_admin_manifest() { - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-env-changed=PROFILE"); - - let profile = std::env::var("PROFILE").unwrap_or_default(); - if profile != "release" { - return; - } - - use embed_manifest::manifest::{ExecutionLevel, SupportedOS}; - use embed_manifest::{embed_manifest, new_manifest}; - - let manifest = new_manifest("Microsoft.MxcPlm") - .requested_execution_level(ExecutionLevel::RequireAdministrator) - .supported_os(SupportedOS::Windows10..); - - embed_manifest(manifest).expect("failed to embed application manifest"); } diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index 1083e0d21..e427bd44f 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -8,9 +8,9 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo ## How it works -1. **Capture** — `plm start` calls `wpr -start !AccessFailureProfile -filemode`, enabling the `Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode` and `Microsoft-Windows-Kernel-General` ETW providers in a secure realtime collector. +1. **Capture** — the public `plm.exe` runs `asInvoker`; it does not add a service. It uses UAC only to launch a hidden restricted child for the fixed WPR start/stop/cancel controls, and that child uses the embedded profile only. 2. **Run** — the operator runs the workload. The OS-side permissive sandbox logs `EventID=14` / `EventID=27` for every access that *would* have been denied. -3. **Stop** — `plm stop` calls `wpr -stop ` and analyzes the sealed ETL through `EtlDenialAnalyzer`. +3. **Stop** — the restricted child returns ETL bytes and status over a unique local authenticated named pipe. The unelevated parent owns caller-selected `--trace-output`, `--log-dir`, and `--config-path` destinations, then analyzes the ETL through `EtlDenialAnalyzer`. 4. **Emit** — canonical findings are written to `denials.json` in the log directory, and a one-line JSON result reports the trace, denials, and optional adjusted-config paths. 5. **Merge (temporary compatibility)** — file and capability denials are adapted into the existing adjusted-config generator until the shared regeneration engine replaces it. @@ -21,31 +21,48 @@ PLM is invoked automatically by [`wxc-exec --audit`](../../../README.md#audit-mo | File | Role | |-------------------------|-----------------------------------------------------------------------------------| | `src/main.rs` | `clap` dispatch for `plm start` / `plm stop` / `plm log` / `plm extract-caps` | -| `src/start.rs` | `wpr -cancel` (best-effort) + `wpr -start …!AccessFailureProfile -filemode` | +| `src/elevated.rs` | Restricted `runas` child, PID-authenticated local pipe, fixed WPR operations, ETL transfer | +| `src/elevated_protocol.rs` | Bounded success/error/ETL framing shared across the privilege boundary | +| `src/parent_auth.rs` | Mutual-PID one-shot authentication for the wxc-exec singleton handoff | +| `src/start.rs` | Fixed `wpr -start …!AccessFailureProfile -filemode` and cleanup-only cancel | | `src/stop.rs` | `wpr -stop` (or skip with `--trace-file`) + parse + FS/capability merge | | `src/log.rs` | Interactive mode: Enter to start, Enter to stop, then diff vs a blank config | | `src/analysis.rs` | Canonical ETL analysis, denials JSON emission, and temporary config-generator adapter | | `src/access_event.rs` | `LearningModeAccessEvent` plain struct | | `src/extract_caps.rs` | DACL ACE blob decoder; resolves capability SIDs via `DeriveCapabilitySidsFromName` | | `src/config.rs` | JSON load/mutate; FS + capability merge into containment-backend section | -| `src/coordination.rs` | Cross-process singleton named-mutex + bypass-env-var coordination for `plm log` | +| `src/coordination.rs` | Cross-process singleton named-mutex and Ctrl-handler coordination | | `src/wpr_path.rs` | Resolves `wpr.exe` to its absolute `%SystemRoot%\System32` path (PATH-spoof-safe) | -| `src/profile_gen.rs` | Inline WPR profile (`EMBEDDED_WPRP`) + run-time writer that drops `plm.wprp` next to `plm.exe` when missing | +| `src/profile_gen.rs` | Inline, non-overridable WPR profile (`EMBEDDED_WPRP`) | + +## Privilege boundary + +`plm.exe` is `asInvoker`; it does **not** carry a `requireAdministrator` manifest, and no service is added. Caller-selected filesystem operations—including `--log-dir`, `--config-path`, `--trace-file`, `--trace-output`, ETL parsing, denials output, and adjusted-config generation—run under the caller token. + +Only hidden fixed `start`, `stop`, and `cancel` control operations are launched with `ShellExecuteExW("runas")`. Their command line contains only an operation, a strictly validated unique local pipe name, and the unelevated server PID. The elevated child: + +- resolves `wpr.exe` from `GetSystemDirectoryW`; +- always uses the compiled-in profile (there is no public `--wprp` override or arbitrary elevated destination); +- keeps its scratch internal and temporary under an OS-resolved trusted local location with restrictive ACL/integrity handling; +- rejects remote pipes and pipe squatting (`PIPE_REJECT_REMOTE_CLIENTS` and `FILE_FLAG_FIRST_PIPE_INSTANCE`); +- authenticates the pipe server PID, while the parent authenticates the child PID returned by `ShellExecuteExW`; +- uses bounded framing for explicit success, error, and ETL responses. +- for `wxc-exec --audit` and interactive `plm log`, starts an already-elevated owner-death guardian before reporting start success; the guardian cancels only after acquiring the PLM singleton, and refuses to cancel if a new owner won that race. + +`wxc-exec --audit` launches the public PLM process normally. Its host-wide singleton handoff uses a separate mutually PID-authenticated one-shot pipe rather than a spoofable environment variable or hidden bypass flag. ## CLI ### `plm start` -Cancels any in-progress WPR session and starts a new permissive-learning-mode trace. +Starts a new permissive-learning-mode trace. If another WPR session already owns the host logger, start fails without cancelling that peer recording. + +This command has no public flags; the removed `--wprp` override is no longer accepted. ```powershell -plm.exe start [--wprp ] +plm.exe start ``` -| Flag | Default | Purpose | -|------------|------------------------|---------------------------------------------------------------| -| `--wprp` | `\plm.wprp` | Override the WPR profile path. By default `plm` materializes its embedded profile next to the exe on first use; an existing `plm.wprp` is never overwritten, so operator hand-edits are preserved. | - ### `plm stop` Stops the active trace (or accepts a previously captured one). @@ -56,7 +73,7 @@ plm.exe stop [--config-path ] [--log-dir ] [--bin-path ] [--exit-code ] [--verbose-logging] ``` -`--trace-output` selects the exact ETL destination passed to `wpr -stop`; it cannot be combined with `--trace-file`, which re-processes an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary. +`--trace-output` selects the exact destination the **unelevated parent** writes after receiving ETL bytes from the elevated child; it is never passed to `wpr.exe` or opened by elevated code. It cannot be combined with `--trace-file`, which is an entirely unelevated path for re-processing an existing ETL. `--exit-code` is copied into the canonical `denials.json` summary. `--config-path` temporarily preserves the existing adjusted-config behavior. The adjusted config is written next to the operator's config snapshot in `--log-dir`; there is deliberately no flag to redirect it independently. The write is atomic so a downstream enforcing run never observes a truncated policy. @@ -74,8 +91,10 @@ plm.exe extract-caps --hex-bytes [--verbose-logging] Interactive iteration mode: press Enter to start a trace, run the workload, press Enter again to stop. It then synthesizes a blank config, runs the filesystem merge, and prints the resulting config as a "diff against a blank config" preview. +It also has no public `--wprp` or destination override flags. + ```powershell -plm.exe log [--wprp ] [--verbose-logging] +plm.exe log [--verbose-logging] ``` ## Building @@ -89,7 +108,7 @@ cargo build -p plm --target x86_64-pc-windows-msvc 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`); on first use of `plm start` / `plm log`, `profile_gen::ensure_wprp_next_to_exe` writes it to disk next to the binary if no `plm.wprp` is already present. `build.bat` from the repo root builds `plm.exe` and stages it next to `wxc-exec.exe` for the `--audit` integration. +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. ## Limitations diff --git a/src/host/plm/src/coordination.rs b/src/host/plm/src/coordination.rs index 7e138befa..778e9dbd8 100644 --- a/src/host/plm/src/coordination.rs +++ b/src/host/plm/src/coordination.rs @@ -2,100 +2,15 @@ // Licensed under the MIT License. //! Cross-process coordination primitives shared by `plm.exe` and the -//! `wxc-exec --audit` driver in the `wxc` crate. Centralises the -//! singleton bypass env-var name and the `wait_until_cleared` ctrl- -//! handler helper so the two binaries cannot drift apart and can both -//! exercise the same tested implementation. +//! `wxc-exec --audit` driver. -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; +use std::time::Duration; -/// Set to `true` while a standalone `plm log` invocation is spawning -/// `wpr -start` and has not yet returned. Read by `plm.exe`'s console- -/// control handler so a Ctrl+C arriving in the spawn window is -/// bounded-waited for, instead of issuing `wpr -cancel` against a -/// not-yet-engaged kernel session and leaking it. Lifted into the -/// shared library (rather than living as a `static` inside -/// `plm/src/main.rs`) so the `log` module can flip it directly -/// without a callback round-trip. -pub static PLM_LOG_START_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -/// Maximum time either console-control handler will wait on its -/// in-flight flag (`AUDIT_START_IN_FLIGHT` in `wxc-exec`, -/// `PLM_LOG_START_IN_FLIGHT` in `plm.exe`) before falling through to -/// `wpr -cancel`. Shared between `wxc-exec`'s `dacl_ctrl_handler` -/// (which runs TWO bounded waits back-to-back — the DACL `try_lock` -/// drain and the `wait_until_cleared` call) and `plm.exe`'s -/// `plm_ctrl_handler` so the two binaries cannot drift apart. -/// Lifting the constant here makes drift a compile-time impossibility. -/// -/// The 2s budget is chosen so the combined budget of the wxc-exec -/// handler (`2 * CTRL_HANDLER_DRAIN_TIMEOUT`) stays under the -/// ~5s OS-imposed kill budget for `CTRL_CLOSE_EVENT` / -/// `CTRL_LOGOFF_EVENT` / `CTRL_SHUTDOWN_EVENT`, with ~500ms of -/// slack for the actual `wpr -cancel` spawn. Pinned by -/// `tests::ctrl_handler_drain_timeout_respects_os_budget`. +/// Maximum time the wxc-exec console-control handler waits to acquire +/// the DACL cleanup slot before allowing Windows to terminate the process. +/// The persistent elevated PLM child observes owner death independently. pub const CTRL_HANDLER_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); -/// Env var set by `wxc-exec --audit` before spawning `plm.exe`. When -/// present, the spawned `plm` binary skips its own singleton mutex -/// acquisition because the outer `wxc-exec` already holds it for the -/// whole audit window. Avoids a deadlock between parent and child on -/// the same `Global\Mxc_Plm_Audit` name. -/// -/// NOTE: kept as a signalling path for tests and for direct callers -/// that inherit env normally, but `wxc-exec --audit` itself no longer -/// uses it — `ShellExecuteExW` + `runas` does not propagate the -/// caller's environment block across the elevation boundary, so -/// wxc-exec passes the bypass signal as a hidden CLI flag instead -/// and `main.rs` calls `set_singleton_bypass_override(true)` from -/// clap. See `singleton_bypass_requested`. -pub const SINGLETON_HELD_BY_PARENT_ENV: &str = "MXC_PLM_AUDIT_SINGLETON_HELD"; - -/// Process-wide override for `singleton_bypass_requested`, populated -/// from the hidden `--wxc-singleton-held-by-parent` CLI flag in -/// `plm.exe`'s `main`. Needed because `ShellExecuteExW` + `runas` -/// (the elevation path used by `wxc-exec --audit`) creates the -/// elevated child with a fresh environment block for the elevated -/// token, so the env-var signalling path cannot reach us. -static SINGLETON_BYPASS_OVERRIDE: AtomicBool = AtomicBool::new(false); - -/// Set the process-wide singleton-bypass override. Called from -/// `plm.exe`'s `main` after clap parses the hidden -/// `--wxc-singleton-held-by-parent` flag that `wxc-exec` passes when -/// it spawns us elevated. -pub fn set_singleton_bypass_override(v: bool) { - SINGLETON_BYPASS_OVERRIDE.store(v, Ordering::SeqCst); -} - -/// True when the audit-driving parent process has signalled that it -/// already holds the `Global\Mxc_Plm_Audit` singleton and this child -/// should skip acquisition. Reads both the env var (legacy path, -/// still honoured for direct callers and tests) and the CLI-driven -/// override. -pub fn singleton_bypass_requested() -> bool { - SINGLETON_BYPASS_OVERRIDE.load(Ordering::SeqCst) - || std::env::var_os(SINGLETON_HELD_BY_PARENT_ENV).is_some() -} - -/// Spin until `flag` reads `false`, or `timeout` elapses. Polls every -/// `poll_interval`. Returns `true` if the flag cleared in time, -/// `false` on timeout. -/// -/// Used by both `wxc-exec`'s `dacl_ctrl_handler` (waiting for `plm -/// start` to drain before issuing `wpr -cancel`) and `plm.exe`'s -/// `plm_ctrl_handler`. -pub fn wait_until_cleared(flag: &AtomicBool, timeout: Duration, poll_interval: Duration) -> bool { - let deadline = Instant::now() + timeout; - while flag.load(Ordering::SeqCst) { - if Instant::now() >= deadline { - return false; - } - std::thread::sleep(poll_interval); - } - true -} - /// Windows-only shared implementation of the `Global\Mxc_Plm_Audit` /// named-mutex singleton. Both `plm.exe` and `wxc-exec --audit` /// serialize on the same name so two concurrent PLM traces can't share @@ -190,132 +105,19 @@ pub mod singleton { #[cfg(test)] mod tests { use super::*; - use std::sync::Arc; - - // ---- singleton bypass ------------------------------------------------ - // - // The env-var lookup is process-global, so multiple tests racing - // on it would interfere. Serialise them with a module-local mutex. - // (We can't use `serial_test` without pulling in a new dep, and a - // bespoke mutex is sufficient for these two tests.) - use std::sync::Mutex; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn singleton_bypass_requested_returns_false_when_env_unset() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - // SAFETY: the lock above serializes env mutation within this - // test binary. Other test binaries can't see this env var - // (Cargo runs each integration test in its own process), and - // production callers always inherit it from wxc-exec. - std::env::remove_var(SINGLETON_HELD_BY_PARENT_ENV); - assert!(!singleton_bypass_requested()); - } - - #[test] - fn singleton_bypass_requested_returns_true_when_env_set() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - std::env::set_var(SINGLETON_HELD_BY_PARENT_ENV, "1"); - let observed = singleton_bypass_requested(); - std::env::remove_var(SINGLETON_HELD_BY_PARENT_ENV); - assert!(observed); - } - - // the bypass also fires for any non-empty - // value (Windows env "0" is still set), so the parent only needs - // the env var to be present, not equal to "1". Pin that contract - // so a future refactor doesn't tighten the check. - #[test] - fn singleton_bypass_requested_returns_true_for_any_value() { - let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); - std::env::set_var(SINGLETON_HELD_BY_PARENT_ENV, ""); - let observed_empty = singleton_bypass_requested(); - std::env::set_var(SINGLETON_HELD_BY_PARENT_ENV, "0"); - let observed_zero = singleton_bypass_requested(); - std::env::remove_var(SINGLETON_HELD_BY_PARENT_ENV); - assert!(observed_empty, "empty string should still count as set"); - assert!(observed_zero, "\"0\" should still count as set"); - } // ---- ctrl-handler drain budget -------------------------------------- // Pin the OS-budget invariant. Windows imposes a hard ~5s kill // timer on `CTRL_CLOSE_EVENT` / `CTRL_LOGOFF_EVENT` / - // `CTRL_SHUTDOWN_EVENT` handlers. The wxc-exec handler runs two - // back-to-back bounded waits each capped at - // `CTRL_HANDLER_DRAIN_TIMEOUT`, so `2 * CTRL_HANDLER_DRAIN_TIMEOUT` - // must stay under that budget with some slack for the actual - // `wpr -cancel` spawn that follows. A future bump to >2s - // reintroduces the ETW-session leak silently — this test fails - // the build instead. + // `CTRL_SHUTDOWN_EVENT` handlers, so the DACL cleanup wait must + // remain comfortably below that budget. #[test] fn ctrl_handler_drain_timeout_respects_os_budget() { - let combined = CTRL_HANDLER_DRAIN_TIMEOUT - .checked_mul(2) - .expect("2 * timeout overflows"); - assert!( - combined <= Duration::from_millis(4500), - "2 * CTRL_HANDLER_DRAIN_TIMEOUT ({combined:?}) must stay under \ - the ~5s OS kill budget for CTRL_CLOSE/LOGOFF/SHUTDOWN, with \ - ~500ms slack for `wpr -cancel` to spawn" - ); - } - - // ---- wait_until_cleared --------------------------------------------- - - #[test] - fn wait_until_cleared_returns_true_when_flag_already_false() { - let flag = AtomicBool::new(false); - let started = Instant::now(); - assert!(wait_until_cleared( - &flag, - Duration::from_secs(5), - Duration::from_millis(10) - )); - // Should be effectively instantaneous (well under the timeout). - assert!( - started.elapsed() < Duration::from_millis(500), - "no-wait path must not sleep" - ); - } - - #[test] - fn wait_until_cleared_returns_false_on_timeout() { - let flag = AtomicBool::new(true); - let started = Instant::now(); - let result = - wait_until_cleared(&flag, Duration::from_millis(150), Duration::from_millis(20)); - assert!(!result, "timeout must surface as false"); - // Allow generous CI scheduling slop: must wait at least the - // timeout, but not wildly longer. - let elapsed = started.elapsed(); - assert!( - elapsed >= Duration::from_millis(140), - "must wait at least the timeout, waited {elapsed:?}" - ); - assert!( - elapsed < Duration::from_secs(2), - "must not vastly exceed the timeout, waited {elapsed:?}" - ); - } - - #[test] - fn wait_until_cleared_returns_true_when_flag_clears_mid_wait() { - let flag = Arc::new(AtomicBool::new(true)); - let writer_flag = Arc::clone(&flag); - // Clear the flag from a background thread after ~50ms; the - // wait should observe the change and return true well before - // the 5s timeout. - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(50)); - writer_flag.store(false, Ordering::SeqCst); - }); - let started = Instant::now(); - let result = wait_until_cleared(&flag, Duration::from_secs(5), Duration::from_millis(10)); - assert!(result, "flag clearing mid-wait must surface as true"); assert!( - started.elapsed() < Duration::from_secs(2), - "must observe the clear well before the timeout" + CTRL_HANDLER_DRAIN_TIMEOUT <= Duration::from_millis(4500), + "CTRL_HANDLER_DRAIN_TIMEOUT ({CTRL_HANDLER_DRAIN_TIMEOUT:?}) must stay under \ + the ~5s OS kill budget for CTRL_CLOSE/LOGOFF/SHUTDOWN" ); } } diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs new file mode 100644 index 000000000..9983901b7 --- /dev/null +++ b/src/host/plm/src/elevated.rs @@ -0,0 +1,1150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Restricted UAC boundary for PLM's WPR control operations. +//! +//! The public `plm.exe` process remains unelevated. It creates a unique local +//! named pipe, launches a hidden child through `runas`, authenticates that +//! pipe's client PID against the returned process handle, and accepts only the +//! bounded protocol in [`crate::elevated_protocol`]. The child accepts no +//! filesystem paths: it uses the embedded WPR profile and creates its own +//! random temporary directory for profile and ETL files. + +use anyhow::{Context, Result}; +use std::cell::RefCell; +use std::ffi::OsStr; +use std::io::{Read, Write}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::{ + CloseHandle, ERROR_CANCELLED, ERROR_PIPE_CONNECTED, ERROR_PIPE_LISTENING, HANDLE, + INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows::Win32::Storage::FileSystem::{ + FILE_FLAGS_AND_ATTRIBUTES, FILE_FLAG_FIRST_PIPE_INSTANCE, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId, + PIPE_NOWAIT, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, +}; +use windows::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetExitCodeProcess, GetProcessId, OpenProcess, + OpenProcessToken, TerminateProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE, +}; +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, +}; +use crate::secure_scratch::{ProfileGuard, SecureScratch}; + +const PIPE_PREFIX: &str = r"\\.\pipe\mxc-plm-elevated-"; +const WAIT_TIMEOUT_DURATION: Duration = Duration::from_secs(10 * 60); +const POLL_INTERVAL: Duration = Duration::from_millis(10); +const ERROR_NO_DATA: i32 = 232; +const SW_HIDE: i32 = 0; +const CONTROL_DISARM: u8 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GuardControl { + Disarm, +} + +fn parse_guard_control(value: u8) -> Result { + match value { + CONTROL_DISARM => Ok(GuardControl::Disarm), + _ => anyhow::bail!("invalid guarded PLM control message {value}"), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Operation { + Start, + Stop, + Cancel, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GuardAction { + None, + Cancel, + RejectStart, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GuardState { + AwaitingReadiness, + Ready, + OwnerExitedDuringStart, + Armed, + Disarmed, + Cancelled, +} + +#[derive(Debug)] +struct GuardLifecycle { + state: GuardState, +} + +impl GuardLifecycle { + fn new() -> Self { + Self { + state: GuardState::AwaitingReadiness, + } + } + + fn ready(&mut self, owner_alive: bool) -> GuardAction { + match (self.state, owner_alive) { + (GuardState::AwaitingReadiness, true) => { + self.state = GuardState::Ready; + GuardAction::None + } + (GuardState::AwaitingReadiness, false) => { + self.state = GuardState::OwnerExitedDuringStart; + GuardAction::RejectStart + } + _ => GuardAction::RejectStart, + } + } + + fn on_owner_exit(&mut self) -> GuardAction { + match self.state { + GuardState::Ready => { + self.state = GuardState::OwnerExitedDuringStart; + GuardAction::None + } + GuardState::Armed => { + self.state = GuardState::Cancelled; + GuardAction::Cancel + } + _ => GuardAction::None, + } + } + + fn on_start_succeeded(&mut self) -> GuardAction { + match self.state { + GuardState::Ready => { + self.state = GuardState::Armed; + GuardAction::None + } + GuardState::OwnerExitedDuringStart => { + self.state = GuardState::Cancelled; + GuardAction::Cancel + } + _ => GuardAction::RejectStart, + } + } + + fn disarm(&mut self) -> GuardAction { + match self.state { + GuardState::Armed => { + self.state = GuardState::Disarmed; + GuardAction::None + } + GuardState::Cancelled => GuardAction::Cancel, + _ => GuardAction::RejectStart, + } + } + + fn on_pipe_break(&mut self) -> GuardAction { + match self.state { + GuardState::Armed => { + self.state = GuardState::Cancelled; + GuardAction::Cancel + } + _ => GuardAction::None, + } + } + + fn cancel_started_trace(&mut self) -> GuardAction { + match self.state { + GuardState::Ready | GuardState::OwnerExitedDuringStart | GuardState::Armed => { + self.state = GuardState::Cancelled; + GuardAction::Cancel + } + _ => GuardAction::None, + } + } +} + +struct GuardedOwner { + owner: OwnedHandle, + lifecycle: GuardLifecycle, +} + +impl GuardedOwner { + fn open(owner_pid: u32) -> Result { + let owner = OwnedHandle( + unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, owner_pid) } + .context("failed to open guarded PLM owner process")?, + ); + let mut guarded = Self { + owner, + lifecycle: GuardLifecycle::new(), + }; + if guarded.lifecycle.ready(!guarded.has_exited()?) != GuardAction::None { + anyhow::bail!("guarded PLM owner exited before WPR start"); + } + Ok(guarded) + } + + fn has_exited(&self) -> Result { + match unsafe { WaitForSingleObject(self.owner.0, 0) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + wait => anyhow::bail!("guarded PLM owner liveness check failed with {}", wait.0), + } + } + + fn finish_start(&mut self) -> Result<()> { + if self.has_exited()? { + self.lifecycle.on_owner_exit(); + } + match self.lifecycle.on_start_succeeded() { + GuardAction::None => {} + GuardAction::Cancel => { + cancel_after_owner_exit() + .context("failed to cancel PLM trace after owner exit during start")?; + anyhow::bail!("guarded PLM owner exited during WPR start"); + } + GuardAction::RejectStart => { + anyhow::bail!("guarded PLM start reached an invalid lifecycle state") + } + } + if self.has_exited()? { + if self.lifecycle.on_owner_exit() == GuardAction::Cancel { + cancel_after_owner_exit() + .context("failed to cancel PLM trace after owner exit following start")?; + } + anyhow::bail!("guarded PLM owner exited immediately after WPR start"); + } + Ok(()) + } + + fn wait_for_disarm(&mut self, pipe: &mut std::fs::File) -> Result<()> { + loop { + if self.has_exited()? { + if self.lifecycle.on_owner_exit() == GuardAction::Cancel { + cancel_after_owner_exit() + .context("failed to cancel PLM trace after guarded owner exit")?; + } + anyhow::bail!("guarded PLM owner exited before disarm"); + } + + let mut control = [0u8; 1]; + match pipe.read(&mut control) { + Ok(1) => { + if let Err(error) = parse_guard_control(control[0]) { + self.cancel_for_pipe_break()?; + return Err(error); + } + match self.lifecycle.disarm() { + GuardAction::None => return Ok(()), + GuardAction::Cancel => { + anyhow::bail!("guarded PLM cleanup won the disarm race") + } + GuardAction::RejectStart => { + anyhow::bail!("guarded PLM disarm arrived in an invalid state") + } + } + } + Ok(0) => { + self.cancel_for_pipe_break()?; + anyhow::bail!("guarded PLM control pipe closed before disarm"); + } + Ok(_) => unreachable!("one-byte control read returned too many bytes"), + Err(error) if error.raw_os_error() == Some(ERROR_NO_DATA) => { + std::thread::sleep(POLL_INTERVAL); + } + Err(error) => { + self.cancel_for_pipe_break()?; + return Err(error).context("guarded PLM control pipe failed before disarm"); + } + } + } + } + + fn cancel_for_pipe_break(&mut self) -> Result<()> { + if self.lifecycle.on_pipe_break() != GuardAction::Cancel { + return Ok(()); + } + if self.has_exited()? { + cancel_after_owner_exit() + } else { + crate::start::cancel_existing_wpr_trace() + } + } + + fn cancel_after_start_error(&mut self) { + if self.lifecycle.cancel_started_trace() == GuardAction::Cancel { + let _ = crate::start::cancel_existing_wpr_trace(); + } + } +} + +impl Operation { + fn as_arg(self) -> &'static str { + match self { + Self::Start => "start", + Self::Stop => "stop", + Self::Cancel => "cancel", + } + } +} + +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_invalid() { + unsafe { + let _ = CloseHandle(self.0); + } + } + } +} + +/// Live authenticated connection to the elevated START child. +/// +/// Dropping an armed session closes the control pipe and waits for the child +/// to cancel the trace. Call [`Self::disarm`] only after stop and ETL transfer +/// have both succeeded. +pub struct GuardedSession { + pipe: Option, + process: OwnedHandle, + disarmed: bool, +} + +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) + .finish_non_exhaustive() + } +} + +impl GuardedSession { + pub fn disarm(&mut self) -> Result<()> { + if self.disarmed { + return Ok(()); + } + let pipe = self + .pipe + .as_mut() + .context("guarded PLM control connection is already closed")?; + pipe.write_all(&[CONTROL_DISARM]) + .context("failed to send guarded PLM DISARM")?; + pipe.flush().context("failed to flush guarded PLM DISARM")?; + wait_for_child_exit(self.process.0, WAIT_TIMEOUT_DURATION)?; + self.pipe.take(); + self.disarmed = true; + Ok(()) + } +} + +impl Drop for GuardedSession { + fn drop(&mut self) { + if self.disarmed { + return; + } + self.pipe.take(); + let _ = wait_for_child_exit(self.process.0, WAIT_TIMEOUT_DURATION); + } +} + +thread_local! { + static CURRENT_GUARDED_SESSION: RefCell> = const { + RefCell::new(None) + }; +} + +/// Invoke a fixed elevated operation. `trace_destination` is consumed only by +/// the unelevated parent and is never placed on the elevated command line. +pub fn invoke(operation: Operation, trace_destination: Option<&Path>) -> Result<()> { + let executable = std::env::current_exe().context("failed to resolve plm.exe path")?; + invoke_with_executable(&executable, operation, trace_destination) +} + +/// Compatibility entry point used by interactive `plm log`. +pub fn invoke_guarded_start(owner_pid: u32) -> Result<()> { + CURRENT_GUARDED_SESSION.with(|slot| { + if slot.borrow().is_some() { + anyhow::bail!("a guarded PLM session is already active on this thread"); + } + let session = start_guarded_session(owner_pid)?; + *slot.borrow_mut() = Some(session); + Ok(()) + }) +} + +pub fn disarm_current_guarded_start() -> Result<()> { + CURRENT_GUARDED_SESSION.with(|slot| { + let mut session = slot + .borrow_mut() + .take() + .context("no guarded PLM session is active on this thread")?; + session.disarm() + }) +} + +pub fn cancel_current_guarded_start() { + CURRENT_GUARDED_SESSION.with(|slot| drop(slot.borrow_mut().take())); +} + +pub fn start_guarded_session(owner_pid: u32) -> Result { + let executable = std::env::current_exe().context("failed to resolve plm.exe path")?; + start_guarded_session_with_executable(&executable, owner_pid) +} + +pub fn start_guarded_session_with_executable( + executable: &Path, + owner_pid: u32, +) -> Result { + if owner_pid == 0 { + anyhow::bail!("guarded elevated start requires a non-zero owner PID"); + } + let (mut pipe, process, deadline) = + launch_connected_child(executable, Operation::Start, Some(owner_pid))?; + if let Err(error) = read_response(&mut pipe, process.0, Operation::Start, None, deadline) { + drop(pipe); + let _ = wait_for_child_exit(process.0, WAIT_TIMEOUT_DURATION); + return Err(error); + } + Ok(GuardedSession { + pipe: Some(pipe), + process, + disarmed: false, + }) +} + +fn invoke_with_executable( + executable: &Path, + operation: Operation, + trace_destination: Option<&Path>, +) -> Result<()> { + match (operation, trace_destination) { + (Operation::Stop, Some(_)) | (Operation::Start | Operation::Cancel, None) => {} + (Operation::Stop, None) => anyhow::bail!("elevated stop requires a trace destination"), + (_, Some(_)) => anyhow::bail!("only elevated stop accepts a trace destination"), + } + + let (mut pipe, process, deadline) = launch_connected_child(executable, operation, None)?; + let result = read_response(&mut pipe, process.0, operation, trace_destination, deadline); + drop(pipe); + let wait_result = wait_for_child_exit( + process.0, + deadline.saturating_duration_since(Instant::now()), + ); + result?; + wait_result +} + +fn launch_connected_child( + executable: &Path, + operation: Operation, + owner_pid: Option, +) -> Result<(std::fs::File, OwnedHandle, Instant)> { + let pipe_name = new_pipe_name()?; + let pipe = OwnedHandle(create_pipe(&pipe_name)?); + if owner_pid.is_some() && operation != Operation::Start { + anyhow::bail!("only elevated start accepts a guarded owner PID"); + } + let process = OwnedHandle(launch_elevated_child( + executable, operation, &pipe_name, owner_pid, + )?); + let child_pid = unsafe { GetProcessId(process.0) }; + if child_pid == 0 { + anyhow::bail!("GetProcessId failed for elevated PLM child"); + } + + let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; + connect_authenticated_client(pipe.0, process.0, child_pid, deadline)?; + + // SAFETY: the pipe handle is valid and uniquely owned. Prevent OwnedHandle + // from closing it a second time after File assumes ownership. + let pipe_raw = pipe.0 .0; + std::mem::forget(pipe); + let pipe_file = unsafe { std::fs::File::from_raw_handle(pipe_raw) }; + Ok((pipe_file, process, deadline)) +} + +fn wait_for_child_exit(process: HANDLE, timeout: Duration) -> Result<()> { + let wait_ms = timeout.as_millis().min(u32::MAX as u128) as u32; + let wait = unsafe { WaitForSingleObject(process, wait_ms) }; + if wait == WAIT_TIMEOUT { + anyhow::bail!("elevated PLM child did not exit within the timeout"); + } + if wait == WAIT_FAILED { + anyhow::bail!("WaitForSingleObject failed for elevated PLM child"); + } + let mut exit_code = 0u32; + unsafe { GetExitCodeProcess(process, &mut exit_code) } + .context("GetExitCodeProcess failed for elevated PLM child")?; + if exit_code != 0 { + anyhow::bail!("elevated PLM child exited with code {exit_code}"); + } + Ok(()) +} + +/// Entry point for the hidden elevated mode. +pub fn run_child( + operation: Operation, + pipe_name: &str, + server_pid: u32, + owner_pid: Option, +) -> Result<()> { + validate_pipe_name(pipe_name)?; + if server_pid == 0 { + anyhow::bail!("invalid elevated PLM server PID"); + } + if !is_process_elevated()? { + anyhow::bail!("internal PLM control mode requires elevation"); + } + + let mut pipe = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(pipe_name) + .with_context(|| format!("failed to connect to PLM control pipe {pipe_name}"))?; + authenticate_server(&pipe, server_pid)?; + if owner_pid.is_some() && operation != Operation::Start { + anyhow::bail!("guarded owner PID is valid only for elevated start"); + } + + if operation == Operation::Start { + if let Some(owner_pid) = owner_pid { + return run_guarded_start_child(&mut pipe, owner_pid); + } + } + + let operation_result = match operation { + Operation::Start => run_start(), + Operation::Stop => run_stop(&mut pipe), + Operation::Cancel => run_cancel(), + }; + + match operation_result { + Ok(()) if operation != Operation::Stop => { + let response_result = + write_header(&mut pipe, ResponseKind::Success, 0).and_then(|_| pipe.flush()); + if let Err(error) = response_result { + if operation == Operation::Start { + let _ = crate::start::cancel_existing_wpr_trace(); + } + return Err(error).context("failed to return elevated PLM success"); + } + Ok(()) + } + Ok(()) => Ok(()), + Err(error) => { + let message = format!("{error:#}"); + let bytes = message.as_bytes(); + let len = (bytes.len() as u64).min(MAX_ERROR_BYTES); + let send_result = write_header(&mut pipe, ResponseKind::Error, len) + .and_then(|_| pipe.write_all(&bytes[..len as usize])) + .and_then(|_| pipe.flush()); + if let Err(send_error) = send_result { + return Err(error).context(format!( + "also failed to return elevated PLM error over the pipe: {send_error}" + )); + } + Err(error) + } + } +} + +fn run_guarded_start_child(pipe: &mut std::fs::File, owner_pid: u32) -> Result<()> { + let mut owner = GuardedOwner::open(owner_pid)?; + if let Err(error) = run_start() { + write_error_response(pipe, &error)?; + return Err(error); + } + if let Err(error) = owner.finish_start() { + owner.cancel_after_start_error(); + write_error_response(pipe, &error)?; + return Err(error); + } + if let Err(error) = write_header(pipe, ResponseKind::Success, 0).and_then(|_| pipe.flush()) { + let _ = owner.cancel_for_pipe_break(); + return Err(error).context("failed to return guarded PLM start success"); + } + owner.wait_for_disarm(pipe) +} + +fn write_error_response(pipe: &mut std::fs::File, error: &anyhow::Error) -> Result<()> { + let message = format!("{error:#}"); + let bytes = message.as_bytes(); + let len = (bytes.len() as u64).min(MAX_ERROR_BYTES); + write_header(pipe, ResponseKind::Error, len) + .and_then(|_| pipe.write_all(&bytes[..len as usize])) + .and_then(|_| pipe.flush()) + .context("failed to return guarded PLM error over the pipe") +} + +fn run_start() -> Result<()> { + crate::wpr_path::verify_wpr_present().map_err(anyhow::Error::msg)?; + let scratch = SecureScratch::new()?; + let profile_guard: ProfileGuard = + scratch.write_and_seal_profile(crate::profile_gen::EMBEDDED_WPRP.as_bytes())?; + crate::start::start_plm_trace(scratch.profile_path())?; + drop(profile_guard); + drop(scratch); + Ok(()) +} + +fn run_stop(pipe: &mut std::fs::File) -> Result<()> { + crate::wpr_path::verify_wpr_present().map_err(anyhow::Error::msg)?; + let scratch = SecureScratch::new()?; + crate::stop::stop_plm_trace(scratch.trace_path())?; + let (mut trace_file, len) = scratch.open_trace()?; + if len > MAX_TRACE_BYTES { + anyhow::bail!( + "captured ETL is {len} bytes, exceeding the {} byte transfer limit", + 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 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")?; + if copied != len { + anyhow::bail!( + "elevated PLM trace ended after {copied} bytes, before the expected {len} bytes" + ); + } + Ok(()) +} + +fn run_cancel() -> Result<()> { + crate::wpr_path::verify_wpr_present().map_err(anyhow::Error::msg)?; + crate::start::cancel_existing_wpr_trace() +} + +fn authenticate_server(pipe: &std::fs::File, expected_pid: u32) -> Result<()> { + let mut actual_pid = 0u32; + unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut actual_pid) } + .context("GetNamedPipeServerProcessId failed")?; + if actual_pid != expected_pid { + anyhow::bail!( + "PLM control pipe server PID mismatch: expected {expected_pid}, got {actual_pid}" + ); + } + Ok(()) +} + +fn is_process_elevated() -> Result { + use windows::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + let mut token = HANDLE::default(); + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } + .context("OpenProcessToken failed")?; + let token = OwnedHandle(token); + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0u32; + unsafe { + GetTokenInformation( + token.0, + TokenElevation, + Some(std::ptr::from_mut(&mut elevation).cast()), + std::mem::size_of::() as u32, + &mut returned, + ) + } + .context("GetTokenInformation(TokenElevation) failed")?; + Ok(elevation.TokenIsElevated != 0) +} + +fn read_response( + pipe: &mut std::fs::File, + process: HANDLE, + operation: Operation, + trace_destination: Option<&Path>, + deadline: Instant, +) -> Result<()> { + let header = read_header_polling(pipe, process, deadline)?; + match header.kind { + ResponseKind::Success if operation != Operation::Stop => Ok(()), + ResponseKind::Success => anyhow::bail!("elevated stop returned no ETL payload"), + ResponseKind::Trace if operation == Operation::Stop => { + let destination = trace_destination.context("missing unelevated trace destination")?; + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create trace output directory {}", + parent.display() + ) + })?; + let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| { + format!( + "failed to create unelevated trace temp file in {}", + parent.display() + ) + })?; + copy_exact_polling( + pipe, + temporary.as_file_mut(), + process, + header.payload_len, + deadline, + )?; + temporary + .as_file_mut() + .sync_all() + .context("failed to flush unelevated trace temp file")?; + temporary + .persist(destination) + .map_err(|error| error.error) + .with_context(|| { + format!("failed to persist trace output {}", destination.display()) + })?; + Ok(()) + } + ResponseKind::Trace => anyhow::bail!("unexpected ETL payload for elevated {operation:?}"), + ResponseKind::Error => { + let mut message = vec![0u8; header.payload_len as usize]; + read_exact_polling(pipe, &mut message, process, deadline)?; + anyhow::bail!( + "elevated PLM {operation:?} failed: {}", + String::from_utf8_lossy(&message) + ) + } + } +} + +fn read_header_polling( + pipe: &mut std::fs::File, + process: HANDLE, + deadline: Instant, +) -> Result { + let mut bytes = [0u8; HEADER_LEN]; + read_exact_polling(pipe, &mut bytes, process, deadline)?; + read_header(&mut bytes.as_slice()).context("invalid elevated PLM response header") +} + +fn copy_exact_polling( + pipe: &mut std::fs::File, + output: &mut impl Write, + process: HANDLE, + mut remaining: u64, + deadline: Instant, +) -> Result<()> { + let mut buffer = vec![0u8; 64 * 1024]; + while remaining != 0 { + let amount = remaining.min(buffer.len() as u64) as usize; + let read = read_some_polling(pipe, &mut buffer[..amount], process, deadline)?; + if read == 0 { + anyhow::bail!("elevated PLM pipe closed before the ETL transfer completed"); + } + output + .write_all(&buffer[..read]) + .context("failed to write unelevated ETL output")?; + remaining -= read as u64; + } + Ok(()) +} + +fn read_exact_polling( + pipe: &mut std::fs::File, + mut buffer: &mut [u8], + process: HANDLE, + deadline: Instant, +) -> Result<()> { + while !buffer.is_empty() { + let read = read_some_polling(pipe, buffer, process, deadline)?; + if read == 0 { + anyhow::bail!("elevated PLM pipe closed before the response completed"); + } + buffer = &mut buffer[read..]; + } + Ok(()) +} + +fn read_some_polling( + pipe: &mut std::fs::File, + buffer: &mut [u8], + process: HANDLE, + deadline: Instant, +) -> Result { + loop { + match pipe.read(buffer) { + Ok(read) => return Ok(read), + Err(error) if error.raw_os_error() == Some(ERROR_NO_DATA) => { + ensure_child_running(process, deadline)?; + std::thread::sleep(POLL_INTERVAL); + } + Err(error) => return Err(error).context("failed to read elevated PLM response"), + } + } +} + +fn connect_authenticated_client( + pipe: HANDLE, + process: HANDLE, + expected_pid: u32, + deadline: Instant, +) -> Result<()> { + loop { + match unsafe { ConnectNamedPipe(pipe, None) } { + Ok(()) => break, + Err(error) => { + let raw = (error.code().0 as u32) & 0xffff; + if raw == ERROR_PIPE_CONNECTED.0 { + break; + } + if raw != ERROR_PIPE_LISTENING.0 { + return Err(error).context("ConnectNamedPipe failed"); + } + ensure_child_running(process, deadline)?; + std::thread::sleep(POLL_INTERVAL); + } + } + } + let mut actual_pid = 0u32; + unsafe { GetNamedPipeClientProcessId(pipe, &mut actual_pid) } + .context("GetNamedPipeClientProcessId failed")?; + if actual_pid != expected_pid { + unsafe { + let _ = TerminateProcess(process, 1); + } + anyhow::bail!( + "PLM control pipe client PID mismatch: expected {expected_pid}, got {actual_pid}" + ); + } + Ok(()) +} + +fn ensure_child_running(process: HANDLE, deadline: Instant) -> Result<()> { + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for elevated PLM response"); + } + match unsafe { WaitForSingleObject(process, 0) } { + WAIT_TIMEOUT => Ok(()), + WAIT_OBJECT_0 => anyhow::bail!("elevated PLM child exited before completing its response"), + WAIT_FAILED => anyhow::bail!("WaitForSingleObject failed for elevated PLM child"), + other => anyhow::bail!("unexpected elevated PLM child wait result {}", other.0), + } +} + +fn create_pipe(pipe_name: &str) -> Result { + let wide = to_wide(pipe_name); + const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003; + let open_mode = FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_DUPLEX) | FILE_FLAG_FIRST_PIPE_INSTANCE; + let pipe = unsafe { + CreateNamedPipeW( + PCWSTR(wide.as_ptr()), + open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_NOWAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + 64 * 1024, + 64 * 1024, + 0, + None, + ) + }; + if pipe == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context( + "CreateNamedPipeW failed (the unique first-instance pipe may have been squatted)", + ); + } + Ok(pipe) +} + +fn launch_elevated_child( + executable: &Path, + operation: Operation, + pipe_name: &str, + owner_pid: Option, +) -> Result { + let parameters = build_internal_parameters( + operation, + pipe_name, + unsafe { GetCurrentProcessId() }, + owner_pid, + ); + let verb = to_wide("runas"); + let executable = to_wide(executable.as_os_str()); + let parameters = to_wide(parameters); + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_NOCLOSEPROCESS, + lpVerb: PCWSTR(verb.as_ptr()), + lpFile: PCWSTR(executable.as_ptr()), + lpParameters: PCWSTR(parameters.as_ptr()), + nShow: SW_HIDE, + ..Default::default() + }; + if let Err(error) = unsafe { ShellExecuteExW(&mut info) } { + let raw = (error.code().0 as u32) & 0xffff; + if raw == ERROR_CANCELLED.0 { + anyhow::bail!("UAC prompt was cancelled"); + } + return Err(error).context("ShellExecuteExW(runas) failed for PLM control child"); + } + if info.hProcess.is_invalid() { + anyhow::bail!("ShellExecuteExW returned no elevated PLM process handle"); + } + Ok(info.hProcess) +} + +fn build_internal_parameters( + operation: Operation, + pipe_name: &str, + server_pid: u32, + owner_pid: Option, +) -> String { + let mut parameters = format!( + "__elevated {} --pipe-name {} --server-pid {server_pid}", + operation.as_arg(), + quote_arg(pipe_name) + ); + if let Some(owner_pid) = owner_pid { + parameters.push_str(&format!(" --owner-pid {owner_pid}")); + } + parameters +} + +fn cancel_after_owner_exit() -> Result<()> { + use crate::coordination::singleton::{try_acquire, AcquireError}; + use std::sync::atomic::AtomicIsize; + let singleton = AtomicIsize::new(0); + match try_acquire(&singleton) { + Ok(()) => {} + Err(AcquireError::AlreadyHeld) => { + // A new owner won the abandoned-mutex race. Its WPR session may + // already be active, so this child must never cancel it. + return Ok(()); + } + Err(AcquireError::CreateFailed(error)) => { + return Err(error).context("guarded PLM singleton acquisition failed") + } + } + let result = crate::start::cancel_existing_wpr_trace(); + crate::coordination::singleton::release(&singleton); + result +} + +fn new_pipe_name() -> Result { + let mut random = [0u8; 16]; + getrandom::getrandom(&mut random) + .map_err(|error| anyhow::anyhow!("failed to generate PLM pipe nonce: {error}"))?; + let suffix: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("{PIPE_PREFIX}{suffix}")) +} + +fn validate_pipe_name(pipe_name: &str) -> Result<()> { + let Some(suffix) = pipe_name.strip_prefix(PIPE_PREFIX) else { + anyhow::bail!("invalid PLM control pipe prefix"); + }; + if suffix.len() != 32 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("invalid PLM control pipe nonce"); + } + Ok(()) +} + +fn quote_arg(arg: &str) -> String { + if !arg.is_empty() && !arg.contains([' ', '\t', '"', '\n']) { + return arg.to_string(); + } + let mut output = String::with_capacity(arg.len() + 2); + output.push('"'); + let mut backslashes = 0usize; + for character in arg.chars() { + if character == '\\' { + backslashes += 1; + } else { + if character == '"' { + output.extend(std::iter::repeat_n('\\', backslashes * 2 + 1)); + } else { + output.extend(std::iter::repeat_n('\\', backslashes)); + } + backslashes = 0; + output.push(character); + } + } + output.extend(std::iter::repeat_n('\\', backslashes * 2)); + output.push('"'); + output +} + +fn to_wide(value: impl AsRef) -> Vec { + value + .as_ref() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guarded_start_requires_readiness_before_start() { + let mut lifecycle = GuardLifecycle::new(); + + assert_eq!(lifecycle.on_start_succeeded(), GuardAction::RejectStart); + assert_eq!(lifecycle.state, GuardState::AwaitingReadiness); + assert_eq!(lifecycle.ready(true), GuardAction::None); + assert_eq!(lifecycle.on_start_succeeded(), GuardAction::None); + assert_eq!(lifecycle.state, GuardState::Armed); + } + + #[test] + fn owner_already_dead_rejects_start_without_cancel() { + let mut lifecycle = GuardLifecycle::new(); + + assert_eq!(lifecycle.ready(false), GuardAction::RejectStart); + assert_eq!(lifecycle.state, GuardState::OwnerExitedDuringStart); + assert_eq!(lifecycle.on_pipe_break(), GuardAction::None); + } + + #[test] + fn owner_death_during_start_cancels_after_start() { + let mut lifecycle = GuardLifecycle::new(); + + assert_eq!(lifecycle.ready(true), GuardAction::None); + assert_eq!(lifecycle.on_owner_exit(), GuardAction::None); + assert_eq!(lifecycle.on_start_succeeded(), GuardAction::Cancel); + assert_eq!(lifecycle.state, GuardState::Cancelled); + } + + #[test] + fn owner_death_after_start_cancels_once() { + let mut lifecycle = armed_lifecycle(); + + assert_eq!(lifecycle.on_owner_exit(), GuardAction::Cancel); + assert_eq!(lifecycle.on_owner_exit(), GuardAction::None); + } + + #[test] + fn explicit_disarm_prevents_cancel_and_post_stop_interference() { + let mut lifecycle = armed_lifecycle(); + + assert_eq!(lifecycle.disarm(), GuardAction::None); + assert_eq!(lifecycle.on_owner_exit(), GuardAction::None); + assert_eq!(lifecycle.on_pipe_break(), GuardAction::None); + assert_eq!(lifecycle.state, GuardState::Disarmed); + } + + #[test] + fn stop_failure_leaves_guard_armed() { + let mut lifecycle = armed_lifecycle(); + + // A failed stop sends no DISARM. + assert_eq!(lifecycle.state, GuardState::Armed); + assert_eq!(lifecycle.on_pipe_break(), GuardAction::Cancel); + } + + #[test] + fn pipe_break_cancels_armed_session_once() { + let mut lifecycle = armed_lifecycle(); + + assert_eq!(lifecycle.on_pipe_break(), GuardAction::Cancel); + assert_eq!(lifecycle.on_pipe_break(), GuardAction::None); + } + + #[test] + fn control_protocol_accepts_only_one_byte_disarm() { + assert_eq!( + parse_guard_control(CONTROL_DISARM).unwrap(), + GuardControl::Disarm + ); + for invalid in [0, 2, u8::MAX] { + assert!(parse_guard_control(invalid).is_err()); + } + } + + fn armed_lifecycle() -> GuardLifecycle { + let mut lifecycle = GuardLifecycle::new(); + assert_eq!(lifecycle.ready(true), GuardAction::None); + assert_eq!(lifecycle.on_start_succeeded(), GuardAction::None); + lifecycle + } + + #[test] + fn validates_only_random_local_plm_pipe_names() { + assert!( + validate_pipe_name(r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff") + .is_ok() + ); + for invalid in [ + r"\\server\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff", + r"\\.\pipe\other-00112233445566778899aabbccddeeff", + r"\\.\pipe\mxc-plm-elevated-short", + r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddee/g", + ] { + assert!(validate_pipe_name(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn internal_command_contains_no_filesystem_path_argument() { + let pipe = r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff"; + for operation in [Operation::Start, Operation::Stop, Operation::Cancel] { + let parameters = build_internal_parameters(operation, pipe, 42, None); + assert!(parameters.starts_with("__elevated ")); + assert!(parameters.contains("--pipe-name")); + assert!(parameters.contains("--server-pid 42")); + assert!(!parameters.contains("trace-output")); + assert!(!parameters.contains("wprp")); + assert!(!parameters.contains("log-dir")); + assert!(!parameters.contains("config-path")); + assert!(!parameters.contains("owner-pid")); + } + } + + #[test] + fn guarded_start_command_contains_only_pipe_and_pid_coordinates() { + let pipe = r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff"; + let parameters = build_internal_parameters(Operation::Start, pipe, 42, Some(84)); + assert!(parameters.contains("--server-pid 42")); + assert!(parameters.contains("--owner-pid 84")); + assert!(!parameters.contains("trace-output")); + assert!(!parameters.contains("wprp")); + } + + #[test] + fn quotes_windows_arguments_without_losing_trailing_backslashes() { + assert_eq!(quote_arg("plain"), "plain"); + assert_eq!( + quote_arg(r"C:\path with spaces\"), + r#""C:\path with spaces\\""# + ); + assert_eq!(quote_arg(r#"a"b"#), r#""a\"b""#); + } + + #[test] + fn copies_exact_trace_length() { + let mut input = std::io::Cursor::new(b"trace-and-trailing-data"); + let mut output = Vec::new(); + + copy_exact_len(&mut input, &mut output, 5).unwrap(); + + assert_eq!(output, b"trace"); + assert_eq!(input.position(), 5); + } + + #[test] + fn rejects_short_trace_copy() { + let mut input = std::io::Cursor::new(b"short"); + let mut output = Vec::new(); + + let error = copy_exact_len(&mut input, &mut output, 6).unwrap_err(); + + assert!(error + .to_string() + .contains("ended after 5 bytes, before the expected 6 bytes")); + assert_eq!(output, b"short"); + } +} diff --git a/src/host/plm/src/elevated_protocol.rs b/src/host/plm/src/elevated_protocol.rs new file mode 100644 index 000000000..d49302649 --- /dev/null +++ b/src/host/plm/src/elevated_protocol.rs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Framing shared by the unelevated PLM parent and its restricted elevated child. + +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, +} + +#[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, + _ => { + 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 => 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, + }, + ] { + 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::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}" + ); + } + } +} diff --git a/src/host/plm/src/lib.rs b/src/host/plm/src/lib.rs index 1e7e19809..0e2c986a5 100644 --- a/src/host/plm/src/lib.rs +++ b/src/host/plm/src/lib.rs @@ -10,12 +10,21 @@ pub mod access_event; pub mod analysis; pub mod config; pub mod coordination; +#[cfg(target_os = "windows")] +pub mod elevated; +pub mod elevated_protocol; pub mod extract_caps; pub mod profile_gen; #[cfg(target_os = "windows")] pub mod log; +#[cfg(target_os = "windows")] +pub mod parent_auth; + +#[cfg(target_os = "windows")] +mod secure_scratch; + #[cfg(target_os = "windows")] pub mod start; diff --git a/src/host/plm/src/log.rs b/src/host/plm/src/log.rs index 121b284a2..7cd18aa4a 100644 --- a/src/host/plm/src/log.rs +++ b/src/host/plm/src/log.rs @@ -13,16 +13,13 @@ use chrono::Local; use learning_mode_core::AnalysisResult; use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::analysis::{analyze_trace, legacy_config_inputs, write_detection_summary}; use crate::config::{ deny_file_set, initialize_filesystem, update_from_access_events, write_added_paths_summary, }; -use crate::coordination::PLM_LOG_START_IN_FLIGHT; -use crate::start; -use crate::stop::{stop_plm_trace_with, WprExeStopper}; -use std::sync::atomic::Ordering; +use crate::elevated::{self, Operation}; fn prompt_enter(message: &str) -> Result<()> { print!("{message}"); @@ -41,20 +38,13 @@ fn can_generate_policy_preview(analysis: &AnalysisResult) -> bool { } pub fn run( - wprp_path: &Path, + owner_pid: u32, verbose: bool, on_trace_started: impl FnOnce(), on_trace_stopped: impl FnOnce(), ) -> Result<()> { prompt_enter("Press Enter to start logging...")?; - // Bracket the `wpr -start` spawn so the console-control handler - // in `plm/src/main.rs` waits for it to drain before deciding - // whether to issue `wpr -cancel`. Closes the same race the - // wxc-exec side closes with `AUDIT_START_IN_FLIGHT`. - PLM_LOG_START_IN_FLIGHT.store(true, Ordering::SeqCst); - let start_result = start::start_plm_trace(wprp_path); - PLM_LOG_START_IN_FLIGHT.store(false, Ordering::SeqCst); - start_result?; + elevated::invoke_guarded_start(owner_pid)?; // `wpr -start` has engaged the kernel session. Only NOW mark the // trace active so a stdin-EOF / spawn-fail before this point can't // trip the Ctrl+C handler into `wpr -cancel`ing an unrelated host @@ -68,7 +58,7 @@ pub fn run( // parallel `plm log` invocations from colliding on the same .etl. let stamp = Local::now().format("%Y-%m-%d_%H%M%S%.3f").to_string(); let trace_file: PathBuf = std::env::temp_dir().join(format!("plm_log_{stamp}.etl")); - stop_plm_trace_with(&mut WprExeStopper, &trace_file)?; + elevated::invoke(Operation::Stop, Some(&trace_file))?; // Kernel session is torn down; safe to clear the active flag so // any subsequent Ctrl+C doesn't issue a stale `wpr -cancel`. on_trace_stopped(); diff --git a/src/host/plm/src/main.rs b/src/host/plm/src/main.rs index 71db66a2c..166b6a276 100644 --- a/src/host/plm/src/main.rs +++ b/src/host/plm/src/main.rs @@ -1,21 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Rust port of the permissive learning mode (PLM) PowerShell scripts. +//! Windows-only PLM command-line entry point. //! -//! Subcommands: -//! - `start`: cancel any active WPR trace and start a new one using -//! `plm.wprp!AccessFailureProfile`. -//! - `stop`: stop the trace and process captured events. -//! - `log`: interactive — Enter to start, Enter to stop. -//! - `extract-caps`: standalone ACE decoder. -//! -//! The functional binary wraps WPR / ETW / EventLog APIs that have no -//! cross-platform equivalent and is therefore Windows-only. On -//! Linux/macOS we still compile a stub binary so the crate sits inside -//! the workspace `default-members` list (one members list to maintain, -//! cross-platform CI catches drift); invoking it prints a message and -//! exits non-zero. +//! The public process always runs as the caller. Only the hidden `__elevated` +//! mode is launched through UAC, and that mode accepts fixed start/stop/cancel +//! operations plus authenticated named-pipe coordinates—never filesystem +//! paths selected by the caller. #[cfg(not(target_os = "windows"))] fn main() { @@ -30,110 +21,34 @@ use clap::{Parser, Subcommand}; #[cfg(target_os = "windows")] use std::path::PathBuf; #[cfg(target_os = "windows")] -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; -#[cfg(target_os = "windows")] -use std::time::Duration; +use std::sync::atomic::AtomicIsize; #[cfg(target_os = "windows")] -use plm::coordination::{singleton_bypass_requested, wait_until_cleared, PLM_LOG_START_IN_FLIGHT}; +use plm::elevated::{self, Operation}; #[cfg(target_os = "windows")] -use plm::{extract_caps, log, profile_gen, start, stop}; +use plm::{extract_caps, log, stop}; -/// Raw `HANDLE` value of the named-mutex singleton acquired by -/// `acquire_singleton_if_needed` (zero when unheld). Stashed in a -/// static so the console-control handler can release the host-wide -/// `Global\Mxc_Plm_Audit` guard before `ExitProcess` runs and skips -/// Rust destructors, preventing the retry-on-conflict path in -/// `start_plm_trace` from `wpr -cancel`ing a peer PLM trace. #[cfg(target_os = "windows")] static PLM_SINGLETON_HANDLE: AtomicIsize = AtomicIsize::new(0); -/// Backing storage for `AcquiredSingleton::mark_trace_active` / -/// `clear_trace_active` / `cancel_active_trace`. -/// -/// Kept as a process-wide `static` (not an owned field of -/// `AcquiredSingleton`) for one narrow reason: the Windows console- -/// control handler `plm_ctrl_handler` is an OS-owned `extern "system"` -/// callback with no `self` / captured environment. It can only reach -/// state via process globals. Access from the `main` thread, however, -/// is gated behind `&AcquiredSingleton` methods so it is a -/// compile-time invariant that the trace-active flag can only be -/// mutated while we hold the host-wide singleton mutex — you can't -/// call `mark_trace_active()` in a free function without first -/// producing an `AcquiredSingleton`. -#[cfg(target_os = "windows")] -static PLM_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); - -/// Release the named-mutex singleton if held. Idempotent. #[cfg(target_os = "windows")] fn release_plm_singleton() { plm::coordination::singleton::release(&PLM_SINGLETON_HANDLE); } -/// Cancel any active PLM trace from a context that can't produce an -/// `&AcquiredSingleton` — currently just the ctrl handler, which -/// runs in an OS-owned callback with no captured environment. All -/// non-signal-context callers should use -/// `AcquiredSingleton::cancel_active_trace(&self)` instead so the -/// call site proves the singleton is held. -#[cfg(target_os = "windows")] -fn cancel_active_plm_trace_from_signal() { - if PLM_TRACE_ACTIVE.swap(false, Ordering::SeqCst) { - // Use the kernel-published System32 path. - let _ = plm::wpr_path::wpr_command() - .arg("-cancel") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } -} - -/// RAII wrapper for the host-wide `Global\Mxc_Plm_Audit` singleton. -/// Ownership of the singleton is the precondition for touching the -/// trace-active flag — the methods below take `&self` so a live -/// `AcquiredSingleton` must exist at every call site. #[cfg(target_os = "windows")] struct AcquiredSingleton; -#[cfg(target_os = "windows")] -impl AcquiredSingleton { - /// Mark the kernel ETW session as live; called immediately after - /// `start::start_plm_trace` succeeds. - fn mark_trace_active(&self) { - PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst); - } - - /// Clear the trace-active flag; called after `wpr -stop` drains - /// the kernel session so a subsequent Ctrl+C doesn't issue a - /// stale `wpr -cancel`. - fn clear_trace_active(&self) { - PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst); - } - - /// Issue `wpr -cancel` iff a trace was marked active by this - /// process. Idempotent. Non-signal-context callers use this - /// method; the ctrl handler uses `cancel_active_plm_trace_from_signal`. - fn cancel_active_trace(&self) { - cancel_active_plm_trace_from_signal(); - } -} - #[cfg(target_os = "windows")] impl Drop for AcquiredSingleton { fn drop(&mut self) { - // Cancel any leftover trace before releasing the singleton so - // a caller that returns an error mid-flow can't leak the - // kernel session past our exit. - self.cancel_active_trace(); release_plm_singleton(); } } #[cfg(target_os = "windows")] -fn acquire_singleton_if_needed() -> Result> { - if singleton_bypass_requested() { - // Outer process holds the mutex for the whole audit window; - // re-acquiring here would deadlock. +fn acquire_singleton_if_needed(parent_authorized: bool) -> Result> { + if parent_authorized { return Ok(None); } use plm::coordination::singleton::{try_acquire, AcquireError}; @@ -141,91 +56,25 @@ fn acquire_singleton_if_needed() -> Result> { Ok(()) => Ok(Some(AcquiredSingleton)), Err(AcquireError::AlreadyHeld) => anyhow::bail!( "another PLM trace is already in progress (Global\\Mxc_Plm_Audit held); \ - refusing to start a second concurrent trace — only one NT Kernel Logger \ - session can exist per host" + refusing to interfere with its NT Kernel Logger session" ), - Err(AcquireError::CreateFailed(e)) => { - Err(e).context("CreateMutexW failed for Global\\Mxc_Plm_Audit") + Err(AcquireError::CreateFailed(error)) => { + Err(error).context("CreateMutexW failed for Global\\Mxc_Plm_Audit") } } } -/// Windows console-control handler. Fires on Ctrl+C, Ctrl+Break, -/// console close, logoff, and shutdown. Tears down any in-flight WPR -/// session and releases the singleton mutex before the default handler -/// calls `ExitProcess` (which skips Rust destructors). -/// -/// We poll `PLM_LOG_START_IN_FLIGHT` via `wait_until_cleared` instead -/// of a proper wait-object (Event / condvar) for two reasons: -/// 1. `wpr -start`'s underlying kernel session engagement isn't -/// signalled by any OS-published handle we can wait on; the only -/// transition we can observe is the child `wpr.exe` process -/// returning. Wrapping a Rust `Event` around that in the ctrl -/// handler would still require polling / a spawn-time helper -/// thread purely to `SetEvent`. -/// 2. The polling interval (50ms) is bounded above by -/// `CTRL_HANDLER_DRAIN_TIMEOUT` (2s) which is well under -/// Windows's ~5s ctrl-handler kill budget, so at most ~40 polls -/// fire — negligible CPU, zero cost on the happy path (the flag -/// is normally already clear when the handler runs). -#[cfg(target_os = "windows")] -unsafe extern "system" fn plm_ctrl_handler(_ctrl_type: u32) -> windows::core::BOOL { - // if `plm log`'s `wpr -start` is - // still in flight when Ctrl+C arrives, briefly wait for it to - // settle before deciding whether to issue `wpr -cancel`. Without - // this wait, a cancel that races a not-yet-engaged session is a - // no-op and the kernel session leaks past `plm.exe` exit. - // - // timeout sourced from the - // shared `plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT` so - // `plm.exe` and `wxc-exec`'s `dacl_ctrl_handler` cannot drift - // apart. The const docs explain the ~5s OS kill budget rationale. - // Polls via the shared `wait_until_cleared` helper so the same - // loop is tested in one place — see `plm::coordination::tests`. - let _ = wait_until_cleared( - &PLM_LOG_START_IN_FLIGHT, - plm::coordination::CTRL_HANDLER_DRAIN_TIMEOUT, - Duration::from_millis(50), - ); - cancel_active_plm_trace_from_signal(); - release_plm_singleton(); - // Return FALSE so the default handler still runs and terminates - // the process. Matches `wxc-exec`'s dacl_ctrl_handler pattern. - windows::core::BOOL(0) -} - -#[cfg(target_os = "windows")] -fn install_ctrl_handler() { - use windows::Win32::System::Console::SetConsoleCtrlHandler; - // SAFETY: handler has the correct ABI; Add=TRUE merely appends to - // the OS handler chain. - let _ = unsafe { SetConsoleCtrlHandler(Some(plm_ctrl_handler), true) }; -} - #[derive(Parser, Debug)] #[command( name = "plm", - about = "Rust port of the permissive learning mode PowerShell scripts.", + about = "Capture and analyze permissive learning mode events.", version )] #[cfg(target_os = "windows")] struct Cli { - /// Internal handshake flag used by `wxc-exec --audit` to hand off - /// a directory the elevated `plm.exe` writes its stdout/stderr - /// into. See `redirect_stdio_from_argv`. Hidden from `--help`; - /// not part of the user-facing CLI. Declared here so clap accepts - /// (and ignores) the flag during subcommand parsing. - #[arg(long = "wxc-capture-dir", hide = true)] - _wxc_capture_dir: Option, - - /// Internal handshake flag used by `wxc-exec --audit` to tell us - /// it already holds the `Global\Mxc_Plm_Audit` singleton so we - /// skip acquisition and avoid a deadlock. Companion of - /// `--wxc-capture-dir`; both migrated off the previous env-var - /// mechanism because `ShellExecuteExW` + `runas` does not - /// propagate environment across the elevation boundary. - #[arg(long = "wxc-singleton-held-by-parent", hide = true)] - wxc_singleton_held_by_parent: bool, + /// One-shot, mutually authenticated singleton handoff from wxc-exec. + #[arg(long, hide = true)] + wxc_parent_auth: Option, #[command(subcommand)] cmd: Cmd, @@ -234,198 +83,127 @@ struct Cli { #[derive(Subcommand, Debug)] #[cfg(target_os = "windows")] enum Cmd { - /// Start a new WPR trace using plm.wprp!AccessFailureProfile. - Start { - /// Override path to plm.wprp. Defaults to \plm.wprp. - #[arg(long)] - wprp: Option, - }, - /// Stop the trace and write `trace.etl` into a log directory. + /// Start a trace using PLM's embedded WPR profile. + Start, + /// Stop the trace and analyze it under the caller's token. Stop { - /// Directory for trace.etl, copied input config, and Adjusted_*.json. + /// Directory for trace.etl, denials.json, and config outputs. #[arg(long)] log_dir: Option, - /// Path treated as the application binary's location. Defaults - /// to the directory containing the plm executable. Used as the - /// self-access filter root in the adjusted config. + /// Application binary location used by the self-access filter. #[arg(long)] bin_path: Option, - /// Path to the MXC container config (JSON) to update. + /// MXC config to snapshot and adjust. #[arg(long)] config_path: Option, - /// Re-process a previously captured .etl instead of stopping a - /// live WPR session. When set, `wpr -stop` is skipped and the - /// supplied file is parsed as-is. - #[arg(long)] + /// Analyze an existing ETL without invoking elevated WPR control. + #[arg(long, conflicts_with = "trace_output")] trace_file: Option, - /// Exact destination for the ETL produced by `wpr -stop`. + /// Exact ETL destination written by the unelevated parent. #[arg(long, conflicts_with = "trace_file")] trace_output: Option, - /// Workload exit code to record in the canonical denials JSON. - #[arg(long, default_value_t = 0)] + /// Workload exit code recorded in denials.json. + #[arg(long, default_value_t = 0, allow_hyphen_values = true)] exit_code: i32, - /// Emit per-event/per-ACE diagnostic output. + /// Emit per-event/per-ACE diagnostics. #[arg(long)] verbose_logging: bool, }, - /// Run extract_caps on a hex-encoded ACE blob and print matched - /// capability names. Mirrors the standalone usage of extract_caps.ps1. + /// Decode a hex-encoded ACE blob. ExtractCaps { - /// Hex-encoded ACE buffer (whitespace allowed, even length). #[arg(long)] hex_bytes: String, - /// Emit per-ACE diagnostic output. #[arg(long)] verbose_logging: bool, }, - /// Interactive: press Enter to start logging, press Enter again to stop. + /// Interactively start, stop, and analyze a trace. Log { - /// Override path to plm.wprp. Defaults to \plm.wprp. - #[arg(long)] - wprp: Option, - /// Emit per-event/per-ACE diagnostic output. #[arg(long)] verbose_logging: bool, }, + /// Internal cleanup entry point used for explicit recovery. + #[command(hide = true)] + Cancel, + /// Restricted UAC child. Not a public interface. + #[command(name = "__elevated", hide = true)] + InternalElevated { + #[command(subcommand)] + operation: InternalOperation, + }, +} + +#[derive(Subcommand, Debug)] +#[cfg(target_os = "windows")] +enum InternalOperation { + Start { + #[arg(long)] + pipe_name: String, + #[arg(long)] + server_pid: u32, + #[arg(long)] + owner_pid: Option, + }, + Stop { + #[arg(long)] + pipe_name: String, + #[arg(long)] + server_pid: u32, + }, + Cancel { + #[arg(long)] + pipe_name: String, + #[arg(long)] + server_pid: u32, + }, } #[cfg(target_os = "windows")] fn exe_dir() -> Result { - let exe = std::env::current_exe().context("failed to resolve current exe path")?; - Ok(exe + let executable = std::env::current_exe().context("failed to resolve current exe path")?; + Ok(executable .parent() - .map(|p| p.to_path_buf()) + .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("."))) } -/// Scan argv for `--wxc-capture-dir ` and, if present, redirect -/// this process's stdout/stderr to `/stdout.log` and -/// `/stderr.log`. Called before `Cli::parse()` so any error the -/// runtime prints (including our own arg-parse errors) reaches the -/// capture files. -/// -/// Used when `wxc-exec --audit` launches us elevated via -/// `ShellExecuteExW` + `runas`. That elevation path can inherit -/// neither our stdio handles nor our environment block (the AppInfo -/// service creates the child with a fresh env for the elevated -/// token), so environment-variable–based handoff of the capture -/// paths does not work — we must pass them on the command line. The -/// flag is also declared as a hidden `#[arg(long, hide = true)]` on -/// `Cli` so clap accepts (and ignores) it during subcommand parsing. -/// -/// On file-open failure we silently fall through — the operator -/// loses that stream's diagnostics but the rest of plm still runs. #[cfg(target_os = "windows")] -fn redirect_stdio_from_argv() { - use std::fs::OpenOptions; - use std::os::windows::io::AsRawHandle; - use std::path::Path; - use windows::Win32::Foundation::HANDLE; - use windows::Win32::System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE}; - - let argv: Vec = std::env::args_os().collect(); - let mut dir: Option = None; - let mut i = 1; - while i < argv.len() { - if argv[i] == "--wxc-capture-dir" && i + 1 < argv.len() { - dir = Some(std::path::PathBuf::from(&argv[i + 1])); - break; - } - i += 1; - } - let Some(dir) = dir else { return }; - - fn redirect_one(path: &Path, which: windows::Win32::System::Console::STD_HANDLE) { - // `create_new(true)` maps to `CREATE_NEW` on Windows, which - // fails with `ERROR_FILE_EXISTS` if anything (regular file, - // directory, symlink, junction target — any reparse point) - // already occupies the path. Combined with the caller-side - // random-suffix temp dir (see `plm_launch::run_plm_elevated`), - // this closes the elevation-boundary symlink attack: a same- - // user medium-IL attacker cannot pre-plant `stdout.log` / - // `stderr.log` as a symlink pointing at an admin-only file - // and have this elevated (admin-token) process silently - // append attacker-controllable bytes to that target. - // - // If create_new fails (attacker successfully raced us, or - // some other fs error) we silently give up — the operator - // loses that stream's diagnostics but no privilege boundary - // is crossed. - let Ok(f) = OpenOptions::new().create_new(true).append(true).open(path) else { - return; - }; - let handle = HANDLE(f.as_raw_handle()); - // Leak the file so the handle stays alive for the process's - // lifetime. `SetStdHandle` records the raw handle; if the - // File drops, the handle closes and subsequent writes fail. - std::mem::forget(f); - // SAFETY: `which` is a valid STD_* constant; `handle` was - // just returned from OpenOptions::open and remains valid - // because we forgot the File. - let _ = unsafe { SetStdHandle(which, handle) }; - } - - redirect_one(&dir.join("stdout.log"), STD_OUTPUT_HANDLE); - redirect_one(&dir.join("stderr.log"), STD_ERROR_HANDLE); +fn internal_operation(operation: InternalOperation) -> Result<()> { + let (operation, pipe_name, server_pid) = match operation { + InternalOperation::Start { + pipe_name, + server_pid, + owner_pid, + } => return elevated::run_child(Operation::Start, &pipe_name, server_pid, owner_pid), + InternalOperation::Stop { + pipe_name, + server_pid, + } => (Operation::Stop, pipe_name, server_pid), + InternalOperation::Cancel { + pipe_name, + server_pid, + } => (Operation::Cancel, pipe_name, server_pid), + }; + elevated::run_child(operation, &pipe_name, server_pid, None) } #[cfg(target_os = "windows")] fn main() -> Result<()> { - // If wxc-exec spawned us elevated via ShellExecuteExW+runas, it - // cannot inherit our stdio pipes across the elevation boundary - // AND the AppInfo service that brokers the elevation does not - // propagate our environment block to the elevated child. The - // capture-file directory is therefore passed as a hidden CLI - // argument (`--wxc-capture-dir`) rather than via env; we redirect - // stdout/stderr to files inside it before touching clap so any - // arg-parse errors also reach the operator. Silent no-op when - // the flag is absent (direct user invocation from an elevated - // shell). - redirect_stdio_from_argv(); - - let cli = Cli::parse(); - // Honour the parent-holds-singleton signal wxc-exec passed as a - // CLI flag. Set BEFORE any acquire_singleton_if_needed call so - // the bypass fires. We keep the env-var path in - // singleton_bypass_requested as a compatibility fallback for - // direct callers that inherit env normally (see coordination.rs). - if cli.wxc_singleton_held_by_parent { - plm::coordination::set_singleton_bypass_override(true); - } - let exe = exe_dir()?; - - // Confirm the resolved wpr.exe exists at `%SystemDirectory%` - // before we go further. We rely on `GetSystemDirectoryW` (not - // env-spoofable) plus the OS TrustedInstaller ACL on that - // directory as the trust boundary; see `wpr_path` module docs for - // why we do not run WinVerifyTrust on the resolved binary. - plm::wpr_path::verify_wpr_present() - .map_err(|e| anyhow::anyhow!("wpr.exe check failed: {e}"))?; - - // Install the Ctrl+C handler unconditionally so signals during any - // subcommand (in particular interactive `log`) tear down the WPR - // session and release the singleton before ExitProcess fires. - install_ctrl_handler(); - - match cli.cmd { - Cmd::Start { wprp } => { - let _singleton = acquire_singleton_if_needed()?; - // Default: materialize the embedded `plm.wprp` next to the - // exe if one isn't already there. - let wprp_path = match wprp { - Some(p) => p, - None => profile_gen::ensure_wprp_next_to_exe(&exe) - .context("failed to stage plm.wprp next to plm.exe")?, - }; - start::start_plm_trace(&wprp_path)?; - // `plm start` exits immediately and leaves the kernel ETW - // session running until a later `plm stop` / `wpr -stop`. - // We deliberately do NOT mark PLM_TRACE_ACTIVE here: this - // process is about to exit and can't be the one to cancel - // the session it just kicked off. The matching `plm stop` - // (or wxc-exec's `cancel_active_audit_trace` cleanup path - // on Ctrl+C) is what owns teardown. - Ok(()) + let Cli { + wxc_parent_auth, + cmd, + } = Cli::parse(); + let cmd = match cmd { + Cmd::InternalElevated { operation } => return internal_operation(operation), + public => public, + }; + let authorized_parent_pid = match wxc_parent_auth.as_deref() { + Some(pipe_name) => Some(plm::parent_auth::claim(pipe_name).map_err(anyhow::Error::msg)?), + None => None, + }; + match cmd { + Cmd::Start => { + let _singleton = acquire_singleton_if_needed(authorized_parent_pid.is_some())?; + elevated::invoke(Operation::Start, None) } Cmd::Stop { log_dir, @@ -436,7 +214,7 @@ fn main() -> Result<()> { exit_code, verbose_logging, } => { - let _singleton = acquire_singleton_if_needed()?; + let _singleton = acquire_singleton_if_needed(authorized_parent_pid.is_some())?; let result = stop::run( stop::StopOptions { log_dir, @@ -447,7 +225,7 @@ fn main() -> Result<()> { exit_code, verbose: verbose_logging, }, - &exe, + &exe_dir()?, )?; println!("{}", serde_json::to_string(&result)?); Ok(()) @@ -456,68 +234,40 @@ fn main() -> Result<()> { hex_bytes, verbose_logging, } => { - let caps = extract_caps::extract_caps(&hex_bytes, verbose_logging)?; - for c in extract_caps::sorted_capability_names(&caps) { - println!("{c}"); + for capability in extract_caps::sorted_capability_names(&extract_caps::extract_caps( + &hex_bytes, + verbose_logging, + )?) { + println!("{capability}"); } Ok(()) } - Cmd::Log { - wprp, - verbose_logging, - } => { - let singleton = acquire_singleton_if_needed()?; - // see `Cmd::Start` above — stage the embedded profile if - // missing. - let wprp_path = match wprp { - Some(p) => p, - None => profile_gen::ensure_wprp_next_to_exe(&exe) - .context("failed to stage plm.wprp next to plm.exe")?, - }; - // The interactive `log` flow is the only standalone path - // that holds a live trace inside a single process. We hand - // `log::run` closures that call - // `AcquiredSingleton::mark_trace_active` / - // `clear_trace_active` on the borrowed singleton — the - // `&AcquiredSingleton` methods encode at compile time that - // trace-active can only be set while we hold the host-wide - // singleton mutex. `mark_trace_active` flips the flag only - // AFTER `wpr -start` has actually engaged the kernel - // session, so a stdin-EOF or spawn-fail before that point - // cannot trip the Ctrl+C handler into `wpr -cancel`ing an - // unrelated host WPR session. - let result = if let Some(s) = singleton.as_ref() { - log::run( - &wprp_path, - verbose_logging, - || s.mark_trace_active(), - || s.clear_trace_active(), - ) - } else { - // Singleton bypass path (wxc-exec --audit already - // holds the mutex). No `AcquiredSingleton` exists in - // this process, so we can't gate the flag on it — - // fall back to the free-function path that the ctrl - // handler also uses. The outer process owns cleanup. - log::run( - &wprp_path, - verbose_logging, - || PLM_TRACE_ACTIVE.store(true, Ordering::SeqCst), - || PLM_TRACE_ACTIVE.store(false, Ordering::SeqCst), - ) - }; - // If `log::run` returned Err AND the trace had been marked - // active (start succeeded but stop or later step failed), - // the flag is still set — issue `wpr -cancel` so the NT - // Kernel Logger session doesn't leak until reboot. + Cmd::Log { verbose_logging } => { + let _singleton = acquire_singleton_if_needed(authorized_parent_pid.is_some())?; + let owner_pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + let disarm_error = std::cell::RefCell::new(None); + let result = log::run( + owner_pid, + verbose_logging, + || {}, + || { + if let Err(error) = elevated::disarm_current_guarded_start() { + *disarm_error.borrow_mut() = Some(error); + } + }, + ); if result.is_err() { - if let Some(s) = singleton.as_ref() { - s.cancel_active_trace(); - } else { - cancel_active_plm_trace_from_signal(); - } + elevated::cancel_current_guarded_start(); + } + if let Some(error) = disarm_error.into_inner() { + return Err(error).context("failed to disarm interactive guarded PLM session"); } result } + Cmd::Cancel => { + let _singleton = acquire_singleton_if_needed(authorized_parent_pid.is_some())?; + elevated::invoke(Operation::Cancel, None) + } + Cmd::InternalElevated { .. } => unreachable!("handled before public dispatch"), } } diff --git a/src/host/plm/src/parent_auth.rs b/src/host/plm/src/parent_auth.rs new file mode 100644 index 000000000..2f7eb9d75 --- /dev/null +++ b/src/host/plm/src/parent_auth.rs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Mutual PID authentication for the unelevated wxc-exec → PLM singleton +//! handoff. This replaces the spoofable environment/CLI bypass. + +use std::io::{Read, Write}; +use std::os::windows::io::{AsRawHandle, FromRawHandle}; +use std::time::{Duration, Instant}; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::{ + CloseHandle, ERROR_PIPE_CONNECTED, ERROR_PIPE_LISTENING, HANDLE, INVALID_HANDLE_VALUE, +}; +use windows::Win32::Storage::FileSystem::{ + FILE_FLAGS_AND_ATTRIBUTES, FILE_FLAG_FIRST_PIPE_INSTANCE, +}; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId, + PIPE_NOWAIT, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, +}; + +const PIPE_PREFIX: &str = r"\\.\pipe\mxc-plm-parent-"; +const AUTH_BYTE: u8 = 0xa7; +const AUTH_TIMEOUT: Duration = Duration::from_secs(30); +const ERROR_NO_DATA: i32 = 232; + +pub struct ParentAuthorization { + pipe: HANDLE, + name: String, +} + +impl ParentAuthorization { + pub fn new() -> Result { + let name = new_pipe_name()?; + let wide = to_wide(&name); + // PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE. + let open_mode = FILE_FLAGS_AND_ATTRIBUTES(0x0000_0002) | FILE_FLAG_FIRST_PIPE_INSTANCE; + let pipe = unsafe { + CreateNamedPipeW( + PCWSTR(wide.as_ptr()), + open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_NOWAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + 1, + 0, + 0, + None, + ) + }; + if pipe == INVALID_HANDLE_VALUE { + return Err(format!( + "CreateNamedPipeW failed for PLM parent authorization: {}", + std::io::Error::last_os_error() + )); + } + Ok(Self { pipe, name }) + } + + pub fn pipe_name(&self) -> &str { + &self.name + } + + pub fn authorize(mut self, expected_client_pid: u32) -> Result<(), String> { + let deadline = Instant::now() + AUTH_TIMEOUT; + loop { + match unsafe { ConnectNamedPipe(self.pipe, None) } { + Ok(()) => break, + Err(error) => { + let raw = (error.code().0 as u32) & 0xffff; + if raw == ERROR_PIPE_CONNECTED.0 { + break; + } + if raw != ERROR_PIPE_LISTENING.0 { + return Err(format!( + "ConnectNamedPipe failed for PLM authorization: {error}" + )); + } + if Instant::now() >= deadline { + return Err("timed out waiting for PLM authorization client".to_string()); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + } + let mut actual_pid = 0u32; + unsafe { GetNamedPipeClientProcessId(self.pipe, &mut actual_pid) } + .map_err(|error| format!("GetNamedPipeClientProcessId failed: {error}"))?; + if actual_pid != expected_client_pid { + return Err(format!( + "PLM authorization client PID mismatch: expected {expected_client_pid}, got {actual_pid}" + )); + } + let raw = self.pipe.0; + self.pipe = HANDLE::default(); + // SAFETY: ownership moves from this object into File. + let mut pipe = unsafe { std::fs::File::from_raw_handle(raw) }; + pipe.write_all(&[AUTH_BYTE]) + .and_then(|_| pipe.flush()) + .map_err(|error| format!("failed to send PLM parent authorization: {error}")) + } +} + +impl Drop for ParentAuthorization { + fn drop(&mut self) { + if !self.pipe.is_invalid() { + unsafe { + let _ = CloseHandle(self.pipe); + } + } + } +} + +/// Claim a one-shot authorization created by the direct parent process. +pub fn claim(pipe_name: &str) -> Result { + validate_pipe_name(pipe_name)?; + let parent_pid = direct_parent_pid()?; + let mut pipe = std::fs::OpenOptions::new() + .read(true) + .open(pipe_name) + .map_err(|error| format!("failed to connect to PLM parent authorization: {error}"))?; + let mut server_pid = 0u32; + unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut server_pid) } + .map_err(|error| format!("GetNamedPipeServerProcessId failed: {error}"))?; + if server_pid != parent_pid { + return Err(format!( + "PLM authorization server is not the direct parent: expected {parent_pid}, got {server_pid}" + )); + } + let mut authorization = [0u8; 1]; + let deadline = Instant::now() + AUTH_TIMEOUT; + loop { + match pipe.read_exact(&mut authorization) { + Ok(()) => break, + Err(error) if error.raw_os_error() == Some(ERROR_NO_DATA) => { + if Instant::now() >= deadline { + return Err("timed out reading PLM parent authorization".to_string()); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(format!("failed to read PLM parent authorization: {error}")), + } + } + if authorization[0] != AUTH_BYTE { + return Err("invalid PLM parent authorization byte".to_string()); + } + Ok(server_pid) +} + +fn direct_parent_pid() -> Result { + let current_pid = unsafe { windows::Win32::System::Threading::GetCurrentProcessId() }; + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .map_err(|error| format!("CreateToolhelp32Snapshot failed: {error}"))?; + struct Snapshot(HANDLE); + impl Drop for Snapshot { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.0); + } + } + } + let snapshot = Snapshot(snapshot); + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { Process32FirstW(snapshot.0, &mut entry) } + .map_err(|error| format!("Process32FirstW failed: {error}"))?; + loop { + if entry.th32ProcessID == current_pid { + if entry.th32ParentProcessID == 0 { + return Err("PLM process has no direct parent PID".to_string()); + } + return Ok(entry.th32ParentProcessID); + } + if unsafe { Process32NextW(snapshot.0, &mut entry) }.is_err() { + break; + } + } + Err("could not locate PLM process in ToolHelp snapshot".to_string()) +} + +fn new_pipe_name() -> Result { + let mut random = [0u8; 16]; + getrandom::getrandom(&mut random) + .map_err(|error| format!("failed to generate PLM authorization nonce: {error}"))?; + let suffix: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(format!("{PIPE_PREFIX}{suffix}")) +} + +fn validate_pipe_name(pipe_name: &str) -> Result<(), String> { + let Some(suffix) = pipe_name.strip_prefix(PIPE_PREFIX) else { + return Err("invalid PLM authorization pipe prefix".to_string()); + }; + if suffix.len() != 32 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("invalid PLM authorization pipe nonce".to_string()); + } + Ok(()) +} + +fn to_wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_local_random_authorization_names() { + assert!( + validate_pipe_name(r"\\.\pipe\mxc-plm-parent-00112233445566778899aabbccddeeff").is_ok() + ); + assert!(validate_pipe_name( + r"\\remote\pipe\mxc-plm-parent-00112233445566778899aabbccddeeff" + ) + .is_err()); + assert!(validate_pipe_name(r"\\.\pipe\mxc-plm-parent-short").is_err()); + } +} diff --git a/src/host/plm/src/profile_gen.rs b/src/host/plm/src/profile_gen.rs index cccbe98aa..c8e7d7552 100644 --- a/src/host/plm/src/profile_gen.rs +++ b/src/host/plm/src/profile_gen.rs @@ -1,16 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Materialize `plm.wprp` next to the running `plm` binary on demand. +//! Canonical embedded WPR profile. //! -//! The canonical profile lives inline below as `EMBEDDED_WPRP`. There -//! is no checked-in `plm.wprp` file and no build-time staging — the -//! binary writes the file itself on first use of `plm start` / -//! `plm log` when one isn't already next to the exe. - -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::process; +//! The restricted elevated child materializes these fixed bytes only in its +//! own random temporary directory. Public callers cannot override the profile +//! or select an elevated filesystem path. /// Canonical WPR profile. Edited here directly — there is no /// sibling `plm.wprp` file to keep in sync. `start.rs`'s @@ -85,79 +80,6 @@ pub const EMBEDDED_WPRP: &str = r#" - -### Brief description of your issue - -### Steps to reproduce -1. -2. -3. - -### Expected behavior - -### Actual behavior -``` - -All five sections are **required**. - -**🚀 Feature Request / Idea** — use for new functionality or improvements: - -```markdown -### Description of the new feature / enhancement - - -### Proposed technical implementation details - -``` - -"Description of the new feature / enhancement" is **required**. Omit "Proposed technical implementation details" if there is nothing meaningful to add. - -**📚 Documentation Issue** — use when docs are incorrect, incomplete, or confusing: - -```markdown -### Brief description of your issue - -``` - -This section is **required**. - -**📋 Task** — use for actionable work items: - -```markdown -### Description of the task - - -### Additional context - -``` - -"Description of the task" is **required**. Omit "Additional context" if there is nothing meaningful to add. - -### Choosing the right category - -- Something **used to work** or **doesn't work as documented** → Bug Report -- Proposing **new behavior or capabilities** → Feature Request / Idea -- **Incorrect, missing, or unclear documentation** → Documentation Issue -- A **discrete unit of work** that doesn't fit the above → Task - -### Style guidelines - -- Use the section headers exactly as shown in the skeletons above -- Be specific and concise — avoid vague descriptions like "it doesn't work" -- For bug reports, always include concrete reproduction steps -- For feature requests, explain the *why* (user problem) before the *how* (implementation) -- Reference relevant source files, config fields, or docs when applicable -- If any required field is unknown, **ask for the information rather than fabricating content** - -## Creating Pull Requests - -Pull requests must follow the template in `.github/PULL_REQUEST_TEMPLATE.md`. Complete all checklist items and add content below the separator (`-----`). - -### Required structure - -Every PR body should include: - -1. **Template checklist** — check the boxes that apply (CLA, related issue, copilot-instructions update). -2. **Summary** — a brief description of what the PR does and why. -3. **Issue references** — if the PR is intended to close an issue, use GitHub closing keywords (`Closes #NNN`, `Fixes #NNN`, or `Resolves #NNN`). If the PR is related but does not close an issue, use an unordered list under a "Related Issues" heading (`- #NNN`). - -### Example - -```markdown -- [x] I have signed the [Contributor License Agreement](https://opensource.microsoft.com/cla/). -- [x] This pull request is related to an issue. -- [ ] If this PR changes build commands, project architecture, or key conventions, I have updated [`.github/copilot-instructions.md`](.github/copilot-instructions.md). - ------ - -## Summary - -Brief description of the change. - -Closes #42 -``` - -### Guidelines - -- One PR should address one issue or concern. Avoid bundling unrelated changes. -- If the PR updates build commands, project architecture, or key conventions, update `.github/copilot-instructions.md` in the same PR. -- Draft PRs are appropriate for work-in-progress that needs early feedback. +# MXC (Microsoft eXecution Container) — Copilot Instructions + +## Prerequisites + +The Rust toolchain version is pinned in [`src/rust-toolchain.toml`](../src/rust-toolchain.toml) to match what CI uses (currently 1.93). The pin is honored automatically by `rustup` — running any `cargo` command from `src/` (or below) downloads and selects that channel on first use. To opt out for one-off testing on a different toolchain, use `cargo + ...` or set `RUSTUP_TOOLCHAIN`. When bumping the pinned version, bump the matching `version: 'ms-prod-1.'` lines in the two `.azure-pipelines/templates/*.Build.Job.yml` files in the same commit. + +LSP servers are configured in `.github/lsp.json` for Rust and TypeScript. Install them before use: + +``` +rustup component add rust-analyzer +npm install -g typescript-language-server typescript +``` + +Building or testing the C# SDK (`sdk/dotnet/`) additionally requires the .NET SDK (net8.0 or newer; a net8.0 target is used). + +## Build Commands + +### Full build (Windows) + +``` +build.bat # Release build for current architecture +build.bat --debug # Debug build +build.bat --all # Release build for both x64 and ARM64 +build.bat --with-microvm # Include NanVix micro-VM binaries +``` + +### Full build (Linux) + +``` +./build.sh # Release build +./build.sh --debug # Debug build +./build.sh --rust-only # Only Rust binaries, skip SDK +``` + +### Full build (macOS) + +``` +./build-mac.sh # Release build for native architecture (seatbelt backend) +./build-mac.sh --debug # Debug build +./build-mac.sh --all # Build for both aarch64 and x86_64 +./build-mac.sh --rust-only # Only Rust binaries, skip SDK +``` + +Requires Xcode Command Line Tools and Rust. Produces an unsigned `mxc-exec-mac` binary (codesigning + notarization happen at release time). Schema `0.7.0-alpha` or later required for macOS/Seatbelt backend. + +### Individual components + +``` +# Rust workspace (from src/) +cargo build --release --target x86_64-pc-windows-msvc +cargo build --release --target aarch64-pc-windows-msvc +cargo build --release -p lxc # Linux only — builds lxc-exec +cargo build --release -p mxc_darwin --target aarch64-apple-darwin # macOS only — builds mxc-exec-mac +cargo build --release -p mxc_ffi # C ABI cdylib (mxc_ffi.dll/.so/.dylib) for the C# SDK + +# TypeScript SDK (from sdk/node/) +npm install && npm run build + +# C# SDK (from sdk/dotnet/) +dotnet build Microsoft.Mxc.Sdk.slnx +``` + +### Lint and format + +``` +# Rust (from src/) +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +``` + +### Tests + +``` +# Rust unit tests (from src/) +cargo test --workspace +cargo test -p wxc_common # Single crate +cargo test -p wxc_common -- config_parser # Filter by test name + +# SDK (from sdk/node/) +npm test +npm run test:integration + +# C# SDK (from sdk/dotnet/) +dotnet test Microsoft.Mxc.Sdk.slnx # requires mxc_ffi built (cargo build -p mxc_ffi); resolver finds it in src/target/{debug,release} + +# Local PowerShell helpers — run from repo root, require built binaries +tests\scripts\run_test_configs.ps1 # All test configs via wxc_test_driver +tests\scripts\run_basicprocess_test.ps1 # Single process container test +tests\scripts\run_isolation_session_tests.ps1 # IsolationSession one-shot E2E (requires host with the OS-side IsoSessionOps service) +tests\scripts\run_isolation_session_state_aware_tests.ps1 # IsolationSession state-aware lifecycle E2E (multi-invocation provision/start/exec/stop/deprovision, same host requirements) +tests\scripts\run_wslc_all_tests.ps1 # All WSLc one-shot config tests (Windows, requires a WSL2 host + wslcsdk.dll; skips if absent) +tests\scripts\run_wslc_state_aware_tests.ps1 # WSLc state-aware lifecycle E2E (multi-invocation provision/start/exec/stop/deprovision + warm-reuse + idle-teardown; requires a WSL2 host + staged wxc-wslc-daemon.exe; skips if absent) +tests\scripts\run_windows_sandbox_one_shot_tests.ps1 # Windows Sandbox one-shot E2E (fresh disposable VM per test; requires the Windows Sandbox optional feature) +tests\scripts\run_windows_sandbox_state_aware_tests.ps1 # Windows Sandbox state-aware lifecycle E2E (provision/start/exec*/stop/deprovision; requires the Windows Sandbox optional feature; skips if absent) +tests\scripts\run_lxc_all_tests.sh # All LXC tests (Linux) +tests\scripts\run_bwrap_all_tests.sh # All Bubblewrap tests (Linux, requires bwrap) + +# E2E test crate — Rust executor integration tests (from src/) +cargo test -p wxc_e2e_tests # Invokes MXC binaries directly +cargo test -p wxc_e2e_tests -- --ignored # Include stress tests (run_on_repeat) +``` + +## Architecture + +MXC is a **sandboxed code execution system** with a Rust core and TypeScript SDK layer. + +### Containment backends + +The Rust workspace (`src/`) implements multiple sandboxing backends behind the `ScriptRunner` trait (`core/wxc_common/src/script_runner.rs`): + +| 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` 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 | +| IsolationSession | `wxc-exec.exe` | Windows | `backends/isolation_session/common/src/` — feature-gated behind `isolation_session`, experimental, uses the in-proc `Windows.AI.IsolationSession.Preview` `IsoSessionOps` API. Supports both one-shot (single-invocation lifecycle, via `ScriptRunner`) and state-aware (multi-invocation provision/start/exec/stop/deprovision, via `StatefulSandboxBackend`) modes. Rejects all filesystem policy (`readwritePaths`/`readonlyPaths`/`deniedPaths`) at every phase with `policy_validation` — the backend has no host-folder-sharing primitive. Likewise rejects any supplied `ui` policy at every phase on both surfaces (as `policy_validation` on the state-aware surface; one-shot discards the typed variant and surfaces `backend_error` with the reason in the message): the isolation session isolates the *host's* UI from contained code but does not deny it UI capabilities (window creation, GDI and the session's own clipboard all work inside it), so no `ui` posture is truthful here — there is no value combination that could be accepted instead, which is why there is no acknowledgment-style gate as there is for `network`. The check is presence-based via `ContainerPolicy::ui_specified` (twin of `network_specified`) because `UiPolicy`'s defaults are full lockdown, making an explicit lockdown `ui` indistinguishable by value from an absent one. An omitted `ui` is accepted and applies no restriction — the schema's default-deny reading does not hold on this backend. One-shot additionally rejects `lifecycle.destroyOnExit=false` and `lifecycle.preservePolicy=true` — the in-proc API has no session-lifetime knob, and the default `destroyOnExit=true` matches actual behavior so it is accepted; the state-aware parser already rejects the whole `lifecycle` section. The full per-phase honor matrix for both surfaces is in `docs/isolation-session/state-aware-rust.md`. The container's network is unrestricted (outbound open; a process inside can listen on a localhost-reachable port) and MXC has no primitive to filter or deny it, so provision (and one-shot) accept ONLY the canonical unrestricted-network acknowledgment — `network.defaultPolicy=allow` + `network.allowLocalNetwork=true`, no host rules, no proxy, default enforcement — and refuse anything else (including an absent policy, which defaults to the unenforceable deny) with `policy_validation`; post-provision phases reject any supplied network policy (fixed at provision, tracked via `ExecutionRequest.network_specified`) and inherit an absent one. State-aware provision accepts an optional `appId` (the Package Family Name for a packaged app), carried verbatim inside the returned `sandboxId`; the one-shot surface takes no backend configuration at all (a stray `experimental.isolation_session` payload is accepted and ignored). Streams stdout/stderr, forwards stdin, and switches to ConPTY mode when wxc-exec's stdout is a TTY for `spawnSandbox` parity. | +| WSLc | `wxc-exec.exe` | Windows | `backends/wslc/common/src/` — feature-gated behind `wslc`, experimental, uses the WSLc SDK (`wslcsdk.dll`, loaded at runtime) to run Linux containers in a WSL2 VM. Supports both one-shot (`WSLContainerRunner`, via `ScriptRunner` + streaming `SandboxBackend`) and state-aware (`state_aware.rs` `WslcStateAwareRunner`, via `StatefulSandboxBackend`) modes. Because the WSLc SDK has **no cross-process re-attach**, state-aware keeps the session (VM) + container warm across separate `wxc-exec` phase processes behind a persistent per-user daemon (`wxc-wslc-daemon.exe`, `backends/wslc/daemon/`) that owns the live `WslcSession`/`WslcContainer` handles; phase processes are thin named-pipe clients (`daemon_client.rs`). The daemon runs all SDK calls on one apartment-affine worker thread (so exec is currently serialized across sandboxes — see `docs/wsl/wslc-state-aware.md`). Honors `readwritePaths`/`readonlyPaths` at provision (→ container volumes) + `network.defaultPolicy` (`Block`→`None`, `Allow`→`Bridged`; networking is all-or-nothing — no per-host filtering, since the container lacks `CAP_NET_ADMIN`); rejects `deniedPaths` nested under a mount and rejects proxy/host-filtering at provision. exec honors `network.proxy` **url-form only** (injected as `HTTP_PROXY`/`HTTPS_PROXY`); start/stop/deprovision reject all policy. ID prefix `wslc` (`wslc:<32-hex>`). Idle-timeout is env-overridable via `MXC_WSLC_DAEMON_IDLE_TIMEOUT_SECS`/`MXC_WSLC_DAEMON_IDLE_POLL_SECS`. See `docs/wsl/wslc-state-aware.md`. | +| LXC | `lxc-exec` | Linux | `core/lxc/src/main.rs` + `backends/lxc/common/` | +| Seatbelt | `mxc-exec-mac` | macOS | `core/mxc_darwin/src/main.rs` + `backends/seatbelt/common/` — uses macOS App Sandbox (Seatbelt) profiles for process containment. Requires schema `0.7.0-alpha`+. Supports `network.proxy` via the same cooperative env-var model as Bubblewrap (injects `HTTP_PROXY`/`HTTPS_PROXY` into the sandbox, reusing `wxc_common::unix_proxy_coordinator`; `builtinTestServer` spawns the shared `unix-test-proxy`). See `docs/macos-support/seatbelt-backend.md`. | +| Bubblewrap | `lxc-exec` | Linux | `backends/bubblewrap/common/src/bwrap_runner.rs` — unprivileged sandboxing via Linux user namespaces and `bwrap`. Experimental — requires `--experimental`. Uses shared filesystem/network policy fields; per-host network filtering via `NetworkIptablesManager` from `backends/lxc/common`. See `docs/bwrap-support/bubblewrap-backend.md`. | + +### Config flow + +1. User provides JSON config (file or base64) → `config_deserialize.rs` performs path-aware typed deserialization into the wire model (`wxc_common::wire`) → `config_parser.rs` validates and maps it to `ExecutionRequest` (the internal execution model in `models.rs`) +2. `ExecutionRequest` includes the containment backend selection, process config, filesystem/network policies, and optional experimental features +3. The appropriate `ScriptRunner` implementation executes the process and returns `ScriptResponse` + +### TypeScript layers + +- **SDK** (`sdk/node/`, `@microsoft/mxc-sdk`) — the public API. The one-shot surface (`spawnSandbox` / `spawnSandboxFromConfig` / `spawnSandboxAsync`) builds a `ContainerConfig` from a `SandboxPolicy`, serialises to base64, and spawns the correct native binary (`wxc-exec.exe`, `lxc-exec`, or `mxc-exec-mac`) via `node-pty`. The state-aware surface (`provisionSandbox` / `startSandbox` / `execInSandbox` / `execInSandboxAsync` / `stopSandbox` / `deprovisionSandbox`, in `sdk/node/src/state-aware.ts`) drives a sandbox through a multi-call lifecycle against `StateAwareContainmentBackend` backends; per-(backend, phase) typed `*Config` interfaces and a branded `SandboxId` live in `sdk/node/src/state-aware-types.ts`. Typed wire-format errors live in `sdk/node/src/errors.ts` (closed `ErrorCode` union plus a single `MxcError` class carrying `code: ErrorCode`, mirroring the Rust `MxcError` shape). Platform detection is in `platform.ts`. + +The SDK auto-discovers native binaries by checking `sdk/node/bin//` (npm-packaged) and `src/target//{release,debug}/` (local dev). The `build.bat`/`build.sh`/`build-mac.sh` scripts copy binaries into the SDK bin directory. + +### C# SDK + +- **C# SDK** (`sdk/dotnet/`, `Microsoft.Mxc.Sdk`) — a managed binding that P/Invokes the native `mxc_ffi` library (which wraps the Rust `mxc-sdk` → `mxc_engine`), rather than spawning an executor. `MxcSandbox.Run(policy, command)` / `RunAsync` run a command to completion and return a `RunResult` (`ExitCode`, `TimedOut`, `Stdout`, `Stderr`); policy POCOs (`SandboxPolicy`, `FilesystemPolicy`, `NetworkPolicy`, `UiPolicy`) serialize to the same camelCase JSON the native layer expects. `MxcException` carries a typed `ErrorCode` that mirrors the native `MXC_STATUS_*` codes (parity-gated by `scripts/check-dotnet-errorcode-parity.js`). `Native/NativeMethods.g.cs` is **generated** by csbindgen from the Rust FFI and is **not committed** (gitignored) — the csproj's `GenerateNativeBindings` MSBuild target regenerates it before each C# compile via `cargo build -p mxc_ffi --features dotnetsdk`, so a `dotnet build` needs the Rust toolchain on PATH. `NativeLibraryResolver` finds `mxc_ffi` via `MXC_FFI_DIR`, the assembly dir / `runtimes//native`, or `src/target/{debug,release}`. Projects: `Microsoft.Mxc.Sdk` (library), `Microsoft.Mxc.Sdk.Sample` (console), `Microsoft.Mxc.Sdk.Tests` (xUnit), in `Microsoft.Mxc.Sdk.slnx`. Beyond run-to-completion, it also exposes **streaming** (`MxcSandbox.Spawn` → `MxcSandboxProcess`: `Stream`-based stdio, `Wait`/`WaitAsync`/`Kill`) and the **state-aware lifecycle** (`MxcLifecycle.ProvisionSandbox`/`StartSandbox`/`ExecInSandbox`/`ExecInSandboxAsync`/`StopSandbox`/`DeprovisionSandbox`, with a typed `SandboxId`). + +### Schema system + +- **Stable schemas**: released, immutable schemas live in [`schemas/stable/`](../schemas/stable) (one file per released version) — never edit them after release. +- **Dev schema**: the in-progress schema lives in [`schemas/dev/`](../schemas/dev). It is **generated** from the Rust wire model (`src/core/wxc_common/src/wire.rs`) by the `mxc_schema_gen` tool — **do not hand-edit it**. To change the dev schema, edit the wire model and regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema..json`. `scripts/versioning/check-schema-codegen.js` is a CI gate that regenerates and fails if the committed schema drifts. See [`docs/schema-codegen.md`](../docs/schema-codegen.md). +- **Generated SDK wire types**: `sdk/node/src/generated/wire.ts` is **generated** from the same wire model by the `mxc_schema_gen --ts` TypeScript emitter (`wxc_common::ts_emit`, no third-party generator) — **do not hand-edit it**. It is a drift oracle (not public API); the SDK unit test `sdk/node/tests/unit/wire-conformance.test.ts` asserts the hand-written public types in `sdk/node/src/types.ts` conform to it, and `scripts/versioning/check-sdk-types-codegen.js` is a CI gate that fails if the committed file drifts. Regenerate with `cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- --ts sdk/node/src/generated/wire.ts`. +- **Canonical schema-version source**: `schemas/schema-version.json` — the single source of truth for the schema-version constants (min/maxSupported/state-aware/stable/dev). `scripts/versioning/check-schema-versions.js` enforces that the Rust parser, SDK, and schema filenames all agree with it; do not hand-edit a schema-version constant without updating the canonical file. See [`docs/versioning.md`](../docs/versioning.md) for the full design. +- Config files can reference schemas via `"$schema"` for editor validation. `scripts/versioning/validate-configs.js` validates the `tests/examples` + `tests/configs` corpus against the dev schema in CI. + +### Key documentation (`docs/`) + +Core references: + +- `docs/schema.md` — full JSON configuration schema reference +- `docs/versioning.md` — schema versioning design, experimental feature lifecycle, and promotion process +- `docs/authoring-a-new-feature.md` — step-by-step guide for adding experimental features (which files to touch, in what order) +- `docs/examples.md` — annotated configuration examples (see also `tests/examples/` and `tests/configs/`) +- `docs/diagnostics.md` — diagnostic logging knobs (env vars, log file format) +- `docs/host-prep.md` — `wxc-host-prep.exe` host setup binary (`prepare-system-drive` / `unprepare-system-drive` for the AppContainer ACEs on the system-drive root, plus `prepare-null-device` / `verify-null-device` / `dump-null-device` for the `\Device\Null` security descriptor that AppContainer-based backends require). Owns elevation via embedded `requireAdministrator` manifest — `wxc-exec.exe` no longer self-elevates. +- `docs/sandbox-policy/v1/policy.md` — sandbox policy v1 specification + +Per-backend guides: + +- `docs/process-container/guide.md` — process container (Windows AppContainer / BaseContainer) +- `docs/process-container/UIPolicy_Schema.md` — UI policy schema (JOB_OBJECT_UILIMIT_* mappings) +- `docs/process-container/os-version-support.md` — per-Windows-release policy-support matrix (filesystem / network / UI) +- `docs/lxc-support/lxc-backend.md` — LXC container backend (Linux) +- `docs/macos-support/seatbelt-backend.md` — macOS Seatbelt backend +- `docs/windows-sandbox/windows-sandbox.md` / `docs/windows-sandbox/windows-sandbox-reference.md` — Windows Sandbox backend +- `docs/wsl/wsl-container-getting-started.md` / `docs/wsl/wsl-container-support-plan.md` — WSL Container (WSLC SDK) +- `docs/wsl/wslc-state-aware.md` — WSLc state-aware lifecycle (daemon-backed warm reuse, per-phase policy honor matrix, `wxc-wslc-daemon.exe`, idle-timeout env overrides) +- `docs/wsl/wslc-sdk-bindings.md` — WSLC SDK FFI bindings: `src/backends/wslc/common/src/wslcsdk_sys.rs` is **generated** by bindgen from `wslcsdk.h` (do NOT hand-edit); `wslc_bindings.rs` is a thin facade over it. On every WSLC SDK version bump, regenerate via `scripts/generate-wslc-bindings.ps1` (needs libclang + `bindgen-cli`, required only on the regen machine — normal/CI builds need neither) and commit the regenerated file with the `WSLC_SDK_VERSION` + hash change. See the doc for the full runbook. +- `docs/nanvix-microvm/nanvix.md` / `docs/nanvix-microvm/nanvix-integration-plan.md` — MicroVM via NanVix + +State-aware lifecycle: + +- `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md` — state-aware sandbox lifecycle API (cross-backend wire format, Rust `StatefulSandboxBackend` trait, and dispatcher contract) +- `docs/state-aware-lifecycle/mxc-state-aware-sandbox-api-overview.md` — companion overview to the full state-aware design +- `docs/isolation-session/oneshot.md` — IsolationSession backend, one-shot bringup (experimental, isolated user account per execution via the OS-side service) +- `docs/isolation-session/state-aware-rust.md` — IsolationSession state-aware lifecycle, Rust-layer spec (per-phase config / metadata, policy matrix, idempotence, concurrency, error mapping) +- `docs/isolation-session/state-aware-typescript.md` — IsolationSession state-aware lifecycle, TypeScript SDK spec + +## Key Conventions + +### Experimental features + +New features go under the `experimental` JSON section and are only active when `--experimental` is passed. See `docs/authoring-a-new-feature.md` for the full checklist. The pattern: + +1. Add the field to the Rust wire model (`src/core/wxc_common/src/wire.rs`) under the `Experimental` section, then regenerate the dev schema (`cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema..json`) — do not hand-edit the generated schema +2. Add the matching field to the wire model's `Experimental` struct (`src/core/wxc_common/src/wire.rs`) and the domain `ExperimentalConfig` in `models.rs`, then map wire→domain in `config_parser.rs` (use `From` impls beside the domain type for trivial enum/struct conversions) +3. Guard execution behind `if request.experimental_enabled` in the runner +4. Never modify files in `schemas/stable/` — those are immutable release artifacts + +### Rust workspace structure + +The workspace is organized into six top-level directories under `src/`: + +| Directory | Purpose | Examples | +|-----------|---------|----------| +| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `learning_mode_core/`, `generated/` | +| `backends/` | Backend-specific code (one subfolder per containment backend or backend support component) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `learning_mode/windows`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | +| `ffi/` | Foreign-function-interface crates (C ABI for language bindings) | `mxc_ffi/` | +| `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | +| `testing/` | Test infrastructure crates | `wxc_e2e_tests/`, `wxc_test_driver/`, `wxc_test_proxy/`, `unix_test_proxy/`, `wxc_ui_probe/`, `fuzz/` | +| `tools/` | Developer/diagnostic tools | `mxc_diagnostic_console/` | + +- `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. +- 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 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`. +- The lower-level execution surface lives in `wxc_common::sandbox_process`: the `SandboxBackend` trait (`validate` + `spawn(request, logger, StdioMode) -> Box` + a `diagnose_exit` hook) and the generic `Runner` adapter that bridges any `SandboxBackend` to the run-to-completion `ScriptRunner` (via `spawn(StdioMode::Inherit)` then `wait()`). `SandboxProcess::output_metadata()` carries backend-produced structured outputs after terminal teardown without writing to process-global stdio. `StdioMode::Pipes` hands the caller live stdin/stdout/stderr (what the `mxc-sdk` streaming path uses); `StdioMode::Inherit` lets the child inherit the host's stdio (what the executor binaries use, preserving the TTY under a pty). `SandboxBackend` is implemented for Seatbelt, Bubblewrap, Windows ProcessContainer, and WSLC (on `wslc_common::WSLContainerRunner` itself, which shares one container lifecycle — `start_container` — between its streaming `SandboxBackend` and run-to-completion `ScriptRunner` impls, differing only in where the WSLC SDK's output callbacks write). +- `mxc_ffi` (`ffi/mxc_ffi`, `crate-type = ["cdylib", "staticlib", "lib"]`) is a flat, panic-safe **C ABI over `mxc-sdk`** for language bindings. `mxc_run(policyJson, command, out)` runs a sandbox to completion, filling a `#[repr(C)] MxcRunResult` (status + exit_code + timed_out + owned stdout/stderr/error/output-metadata C strings); every entry point is `catch_unwind`-wrapped so a panic becomes a status code, never an unwind. Its `build.rs` runs **csbindgen** to generate the C# P/Invoke (`sdk/dotnet/Microsoft.Mxc.Sdk/Native/NativeMethods.g.cs`), gated behind the crate's **`dotnetsdk`** feature (off by default, so the whole-workspace backend build matrix doesn't compile csbindgen). The generated file is **not committed** (gitignored); the C# csproj regenerates it at build time and `scripts/check-dotnet-bindings-codegen.js` runs the codegen in CI and asserts the expected entry points are produced. The C ABI is **not a stable external contract** (native + binding are co-versioned and generated together; see the crate docs). It exposes three surfaces: **run-to-completion** (`mxc_run`), **streaming** (`mxc_spawn` → opaque `MxcSandbox` handle; `mxc_stream_read`/`write`/`flush`, `mxc_sandbox_take_stdin`/`stdout`/`stderr`, `mxc_sandbox_id`/`try_wait`/`wait`/`kill`/`output_metadata_json`/`free`, in `src/streaming.rs`), and the **state-aware lifecycle** (`mxc_state_aware` for the envelope phases + `mxc_state_aware_exec` returning a live streaming handle, in `src/state_aware.rs`). All three `.rs` files are csbindgen inputs in `build.rs`; the `MXC_STATUS_*` space already reserves the state-aware phase codes. +- `mxc_pty` is the shared pty bridge used by the LXC backend (`lxc_common::lxc_bindings::attach_run`) so the inner shell sees a real TTY and host stdio is streamed live. (Seatbelt and Bubblewrap no longer use it: they spawn directly and let the child inherit the host's stdio — a TTY when the executor binary runs under a pty — via `SandboxBackend::spawn(StdioMode::Inherit)`.) +- `learning_mode_core` is the **cross-platform learning-mode / captureDenials model + output emitter**: `DeniedResource` (+ `ResourceType`/`AccessType`), `DenialSummary`, the `DenialAnalyzer` decode trait, and `emit` — which writes the on-disk denials deliverable as a **single JSON document** `{ "denials": [...], "summary": {...} }` (`write_document` / `DenialsDocument`) and defines the serializable `DenialsOutputPointer`. It carries no OS-specific code (must not depend on any `backends/*` crate); the Windows ETL decoder implementing `DenialAnalyzer` lives in `backends/learning_mode/windows`. When `processContainer.captureDenials` is set, the BaseContainer runner seals a unique internal ETL temp, decodes it via that backend with bounded event/unique-denial processing, writes the JSON file (caller's `outputPath` with a unique per-run identifier stamped into the stem, e.g. `denials..json`, or a managed temp), deletes the ETL, and returns neutral `wxc_common` output metadata. `wxc-exec` serializes that metadata as the one-line stderr pointer at the CLI boundary; Rust/C#/FFI callers receive it programmatically. Each denial's `resource` field holds the file path or the AppContainer capability name; capability denials resolve their capability SID to a friendly name via `backends/learning_mode/windows`'s `capability_names` (well-known `S-1-15-3-…` SID → policy name; custom hashed SIDs fall back to the SID string). +- `mxc_build_common` is a build-time helper crate — all Windows binary crates use it in their `build.rs` to embed VersionInfo (ProductName, FileDescription, copyright, version+commit). When adding a new Windows binary crate, add `mxc_build_common` as a build-dependency and call `mxc_build_common::embed_version_info()` from `build.rs` +- `nanvix_build_common` is a **build-only** helper crate (never linked into the runtime): it stages NanVix binaries next to the executable and resolves the `NANVIX_BIN` prefetch directory. The `nanvix_binaries`, `wxc`, and `lxc` build scripts consume it as a `[build-dependencies]` entry. Runtime constants it needs (binary/snapshot filenames) stay in `nanvix_common`. Keep build-only file-staging logic here, not in `nanvix_common` (which is a runtime dependency of `nanvix_runner`). +- Platform-specific modules use `#[cfg(target_os = "windows")]` / `#[cfg(target_os = "linux")]` +- Workspace edition is 2021; shared dependencies are declared in the root `Cargo.toml` `[workspace.dependencies]` + +### Config parser pattern + +The parser deserializes JSON directly into the typed wire model (`wxc_common::wire`), the single source of truth for the config shape (it also generates the JSON schema). All typed config deserialization goes through `config_deserialize.rs`, which distinguishes syntax errors from typed policy errors and adds the complete JSON path plus source line/column when available; state-aware backend errors are prefixed with their full `experimental..` location. `config_parser.rs` then maps the wire types to the validated domain structs in `models.rs`. The stable surface uses `deny_unknown_fields` (closed); the `experimental` block is permissive. + +### TypeScript conventions + +- Target ES2022, ESM modules (`module`/`moduleResolution: NodeNext`, `"type": "module"`), strict mode — relative imports use explicit `.js` extensions +- Tests use Node.js built-in test runner (`node --test`) + +### Binary naming + +- Windows: `wxc-exec.exe` (AppContainer / Windows Sandbox / MicroVM); `wxc-host-prep.exe` (host setup — see `docs/host-prep.md`) +- Linux: `lxc-exec` (LXC containers) +- macOS: `mxc-exec-mac` (Seatbelt) +- Target triples: `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `aarch64-apple-darwin` + +### Package versioning + +All Rust crates use `version.workspace = true` to inherit the version from `src/Cargo.toml` `[workspace.package]`. The npm SDK version in `sdk/node/package.json` and the C# SDK version (`` in `sdk/dotnet/Microsoft.Mxc.Sdk/Microsoft.Mxc.Sdk.csproj`) must match. Run `node scripts/check-version-sync.js` to validate they are in sync. When bumping the version, update `src/Cargo.toml` (workspace version), `sdk/node/package.json`, and the C# csproj in the same commit. + +### Keeping docs up to date + +When changing behavior covered by existing documentation, update the relevant docs in the same change: + +- **Schema changes** (adding/removing/renaming config fields) → update `docs/schema.md` and the appropriate JSON schema in `schemas/dev/` or `schemas/stable/` +- **New experimental features** → follow `docs/authoring-a-new-feature.md`, which includes schema, Rust, and test config steps +- **SDK API changes** (new exports, changed signatures, new options) → update `sdk/node/README.md` and the JSDoc in `sdk/node/src/index.ts` (TypeScript SDK); the Rust `mxc-sdk` crate docs/`README.md`; and `sdk/dotnet/README.md` (C# SDK). If the `mxc_ffi` C ABI surface changes, the C# P/Invoke regenerates on the next C# build; keep the `ErrorCode` parity + bindings-codegen gates green. +- **New containment backends or major backend changes** → update the relevant doc in `docs/` (e.g., `lxc-support/lxc-backend.md`, `windows-sandbox/windows-sandbox.md`) +- **Versioning or promotion changes** → update `docs/versioning.md` + +### Policy versioning + +The `SandboxPolicy.version` in the SDK must match a JSON schema version in the supported range (`0.6.0-alpha` minimum, `0.8.0-alpha` maximum). The SDK validates this in `sandbox.ts` — if the policy version is older than `MIN_VERSION` or newer than `SUPPORTED_VERSION` it throws. State-aware lifecycle requests use `0.6.0-alpha`. These bounds are mirrored from the canonical `schemas/schema-version.json` and enforced by `scripts/versioning/check-schema-versions.js`. See `docs/versioning.md` for the full design. + +## Creating Issues + +When creating issues in this repository, follow the structure defined by the issue templates in `.github/ISSUE_TEMPLATE/`. Every issue **must** match one of the four categories below and include the corresponding labels, issue type, and required fields. + +### Issue categories, types, and labels + +| Category | GitHub Issue Type | Labels | Template | +|----------|------------------|--------|----------| +| 🐛 Bug Report | `Bug` | `Issue-Bug`, `Needs-Triage` | `Bug_Report.yml` | +| 🚀 Feature Request / Idea | `Feature` | `Issue-Feature`, `Needs-Triage` | `Feature_Request.yml` | +| 📚 Documentation Issue | `Task` | `Issue-Docs`, `Needs-Triage` | `Documentation_Issue.yml` | +| 📋 Task | `Task` | `Issue-Task`, `Needs-Triage` | `Task.yml` | + +- Always apply `Needs-Triage` alongside the category-specific label. +- Apply exactly the labels listed above — do not invent new labels. +- When creating issues via the API, set labels and issue type explicitly — they are not applied automatically. + +### Required body structure by category + +Issues created via the API or by agents do not inherit the form layout from the YAML templates. Reproduce the structure in the issue body using the markdown skeletons below. + +**🐛 Bug Report** — use when something is broken or behaving unexpectedly: + +> ⚠️ **Security notice:** When reporting BSODs or security issues, **DO NOT** attach memory dumps, logs, or traces to GitHub issues. Instead, send them to secure@microsoft.com referencing the GitHub issue. For application crashes, include a Feedback Hub link if possible (open with Win+F, choose "Share My Feedback" after submission). + +```markdown +### Relevant area(s) + + +### Brief description of your issue + +### Steps to reproduce +1. +2. +3. + +### Expected behavior + +### Actual behavior +``` + +All five sections are **required**. + +**🚀 Feature Request / Idea** — use for new functionality or improvements: + +```markdown +### Description of the new feature / enhancement + + +### Proposed technical implementation details + +``` + +"Description of the new feature / enhancement" is **required**. Omit "Proposed technical implementation details" if there is nothing meaningful to add. + +**📚 Documentation Issue** — use when docs are incorrect, incomplete, or confusing: + +```markdown +### Brief description of your issue + +``` + +This section is **required**. + +**📋 Task** — use for actionable work items: + +```markdown +### Description of the task + + +### Additional context + +``` + +"Description of the task" is **required**. Omit "Additional context" if there is nothing meaningful to add. + +### Choosing the right category + +- Something **used to work** or **doesn't work as documented** → Bug Report +- Proposing **new behavior or capabilities** → Feature Request / Idea +- **Incorrect, missing, or unclear documentation** → Documentation Issue +- A **discrete unit of work** that doesn't fit the above → Task + +### Style guidelines + +- Use the section headers exactly as shown in the skeletons above +- Be specific and concise — avoid vague descriptions like "it doesn't work" +- For bug reports, always include concrete reproduction steps +- For feature requests, explain the *why* (user problem) before the *how* (implementation) +- Reference relevant source files, config fields, or docs when applicable +- If any required field is unknown, **ask for the information rather than fabricating content** + +## Creating Pull Requests + +Pull requests must follow the template in `.github/PULL_REQUEST_TEMPLATE.md`. Complete all checklist items and add content below the separator (`-----`). + +### Required structure + +Every PR body should include: + +1. **Template checklist** — check the boxes that apply (CLA, related issue, copilot-instructions update). +2. **Summary** — a brief description of what the PR does and why. +3. **Issue references** — if the PR is intended to close an issue, use GitHub closing keywords (`Closes #NNN`, `Fixes #NNN`, or `Resolves #NNN`). If the PR is related but does not close an issue, use an unordered list under a "Related Issues" heading (`- #NNN`). + +### Example + +```markdown +- [x] I have signed the [Contributor License Agreement](https://opensource.microsoft.com/cla/). +- [x] This pull request is related to an issue. +- [ ] If this PR changes build commands, project architecture, or key conventions, I have updated [`.github/copilot-instructions.md`](.github/copilot-instructions.md). + +----- + +## Summary + +Brief description of the change. + +Closes #42 +``` + +### Guidelines + +- One PR should address one issue or concern. Avoid bundling unrelated changes. +- If the PR updates build commands, project architecture, or key conventions, update `.github/copilot-instructions.md` in the same PR. +- Draft PRs are appropriate for work-in-progress that needs early feedback. diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index f3843dff0..a03211109 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -2017,7 +2017,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( diff --git a/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs b/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs index 091b5c8fc..cbad6dabb 100644 --- a/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs +++ b/src/backends/learning_mode/windows/src/guarded_wpr_protocol.rs @@ -9,7 +9,7 @@ const MAGIC: &[u8; 8] = b"MXCPLM01"; const VERSION: u8 = 1; pub const HEADER_LEN: usize = 20; const ATTACH_HANDLES_MAGIC: &[u8; 8] = b"MXCATT01"; -const ATTACH_HANDLES_LEN: usize = 24; +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; diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index d850003e3..8faf7b589 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -67,13 +67,14 @@ use windows::Win32::System::IO::{ use windows::Win32::UI::Shell::{ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW}; use crate::elevated_protocol::{ - read_attach_handles, read_header, write_attach_handles, write_header, ResponseKind, HEADER_LEN, - MAX_ANALYSIS_BYTES, 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); const POLL_INTERVAL: Duration = Duration::from_millis(10); const TRANSFER_POLL_INTERVAL: Duration = Duration::from_millis(1); const SW_HIDE: i32 = 0; @@ -354,7 +355,10 @@ impl GuardedOwner { return run_guarded_stop(pipe, self, StopDisposition::Discard); } Ok(GuardControl::AttachJob) => { - let (job_handle, root_process_handle) = match read_attach_handles(pipe) { + 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(); @@ -1386,6 +1390,43 @@ enum StopDisposition { 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, @@ -2389,8 +2430,7 @@ mod tests { child.wait().unwrap(); } - #[test] - fn empty_connected_pipe_is_not_reported_as_closed() { + 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(); @@ -2422,12 +2462,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)); @@ -2446,6 +2492,23 @@ mod tests { panic!("closed test pipe remained connected"); } + #[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); From 9cefcb9808f924f6dd9008da406db5601a12cbd3 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 13 Aug 2026 11:38:32 -0700 Subject: [PATCH 42/50] Restrict external denied-path assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/backends/appcontainer/common/src/appcontainer_runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backends/appcontainer/common/src/appcontainer_runner.rs b/src/backends/appcontainer/common/src/appcontainer_runner.rs index 791e5da67..08fa6b38f 100644 --- a/src/backends/appcontainer/common/src/appcontainer_runner.rs +++ b/src/backends/appcontainer/common/src/appcontainer_runner.rs @@ -615,7 +615,7 @@ impl AppContainerScriptRunner { } /// Marks `deniedPaths` as enforced by the dispatcher's per-run DACL guard. - pub fn with_external_denied_paths(mut self) -> Self { + pub(crate) fn with_external_denied_paths(mut self) -> Self { self.denied_paths_enforced_externally = true; self } From 959361dcaf6c953ea98d48cf95997c8e901c3e6d Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 13 Aug 2026 11:56:26 -0700 Subject: [PATCH 43/50] Fail closed on guarded tracker errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/host/plm/src/elevated.rs | 281 +++++++++++++++++++++++++++++++++-- 1 file changed, 265 insertions(+), 16 deletions(-) diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index 8faf7b589..958895ef8 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -45,7 +45,8 @@ use windows::Win32::Storage::FileSystem::{ use windows::Win32::System::JobObjects::{ IsProcessInJob, JobObjectAssociateCompletionPortInformation, JobObjectBasicAccountingInformation, QueryInformationJobObject, SetInformationJobObject, - JOBOBJECT_ASSOCIATE_COMPLETION_PORT, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + TerminateJobObject, JOBOBJECT_ASSOCIATE_COMPLETION_PORT, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, }; use windows::Win32::System::Pipes::{ ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, GetNamedPipeServerProcessId, @@ -324,6 +325,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)), @@ -425,6 +430,64 @@ impl GuardedOwner { .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(); @@ -504,6 +567,7 @@ struct ProcessTrackerState { notification_sequence: usize, active_process_zero_filetime: Option, error: Option, + termination_error: Option, } impl ProcessTrackerState { @@ -519,6 +583,7 @@ impl ProcessTrackerState { notification_sequence: 0, active_process_zero_filetime: None, error: None, + termination_error: None, } } @@ -645,6 +710,11 @@ struct JobProcessTracker { worker: Option>, } +struct TrackerFailure { + message: String, + termination_error: Option, +} + impl JobProcessTracker { fn duplicate_and_attach( owner: HANDLE, @@ -820,6 +890,28 @@ impl JobProcessTracker { }) } + 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) { if let Some(worker) = self.worker.take() { let _ = unsafe { @@ -901,9 +993,11 @@ fn process_job_notifications(port: HANDLE, job: HANDLE, state: &Arc { - state.process_started(pid, observed_filetime, || attest_job_process(job, pid)); + tracker_state + .process_started(pid, observed_filetime, || attest_job_process(job, pid)); } JOB_OBJECT_MSG_EXIT_PROCESS | JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS => { - state.process_exited(pid, observed_filetime); + tracker_state.process_exited(pid, observed_filetime); } JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO => { - state.all_processes_exited(observed_filetime); + 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, 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); + } } } @@ -1022,9 +1151,8 @@ impl GuardedSession { .pipe .take() .context("guarded PLM control connection is already closed")?; - pipe.write_all(&[CONTROL_STOP]) + send_control_unless_response_pending(&mut pipe, CONTROL_STOP) .context("failed to send guarded PLM STOP")?; - pipe.flush().context("failed to flush guarded PLM STOP")?; let stopped = std::cell::Cell::new(false); let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; @@ -1062,10 +1190,8 @@ impl GuardedSession { .pipe .take() .context("guarded PLM control connection is already closed")?; - pipe.write_all(&[CONTROL_STOP_AND_DISCARD]) + send_control_unless_response_pending(&mut pipe, CONTROL_STOP_AND_DISCARD) .context("failed to send guarded PLM discard STOP")?; - pipe.flush() - .context("failed to flush guarded PLM discard STOP")?; let stopped = std::cell::Cell::new(false); let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; @@ -1103,10 +1229,8 @@ impl GuardedSession { .pipe .take() .context("guarded PLM control connection is already closed")?; - pipe.write_all(&[CONTROL_STOP_AND_ANALYZE]) + send_control_unless_response_pending(&mut pipe, CONTROL_STOP_AND_ANALYZE) .context("failed to send guarded PLM analyzed STOP")?; - pipe.flush() - .context("failed to flush guarded PLM analyzed STOP")?; let stopped = std::cell::Cell::new(false); let deadline = Instant::now() + WAIT_TIMEOUT_DURATION; @@ -1131,6 +1255,64 @@ impl GuardedSession { } } +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 { fn drop(&mut self) { if self.disarmed { @@ -2355,6 +2537,34 @@ mod tests { assert_eq!(state.error.as_deref(), Some("terminal tracker failure")); } + #[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] { @@ -2492,6 +2702,45 @@ 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(); From c7620e38582b6a05b50ec1bacf315a7e79f6e513 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 13 Aug 2026 16:16:47 -0700 Subject: [PATCH 44/50] Keep enforcement until guarded capture release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- .../common/src/appcontainer_runner.rs | 7 ++- .../common/src/base_container_runner.rs | 7 ++- .../common/src/guarded_capture.rs | 8 +++- src/core/mxc_engine/src/guarded_capture.rs | 24 ++++++++-- src/host/plm/src/elevated.rs | 44 +++++++++++++++---- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/backends/appcontainer/common/src/appcontainer_runner.rs b/src/backends/appcontainer/common/src/appcontainer_runner.rs index 04433dc03..f46db0af7 100644 --- a/src/backends/appcontainer/common/src/appcontainer_runner.rs +++ b/src/backends/appcontainer/common/src/appcontainer_runner.rs @@ -1694,10 +1694,13 @@ impl AppContainerSandboxProcess { result.map_err(std::io::Error::other) } - fn discard_guarded_capture_after_termination_failure(&mut self) { + fn release_guarded_capture_after_termination_failure(&mut self) { let Some(mut 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) = session.discard() { capture_output::write_stderr_line_best_effort(format_args!( "failed to discard guarded WPR capture after sandbox termination failure: {error}" @@ -1818,7 +1821,7 @@ impl Drop for AppContainerSandboxProcess { capture_output::write_stderr_line_best_effort(format_args!( "failed to terminate sandbox job during drop: {error}" )); - self.discard_guarded_capture_after_termination_failure(); + self.release_guarded_capture_after_termination_failure(); return; } unsafe { diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 331813023..ae5574d34 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -2715,10 +2715,13 @@ impl BaseContainerSandboxProcess { result.map_err(std::io::Error::other) } - fn discard_guarded_capture_after_termination_failure(&mut self) { + fn release_guarded_capture_after_termination_failure(&mut self) { let Some(mut 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) = session.discard() { write_stderr_line_best_effort(format_args!( "failed to discard guarded WPR capture after sandbox termination failure: {error}" @@ -2991,7 +2994,7 @@ impl Drop for BaseContainerSandboxProcess { write_stderr_line_best_effort(format_args!( "failed to terminate sandbox process tree during drop: {error}" )); - self.discard_guarded_capture_after_termination_failure(); + self.release_guarded_capture_after_termination_failure(); return; } // A dropped handle has no observer for output metadata, so retaining diff --git a/src/backends/appcontainer/common/src/guarded_capture.rs b/src/backends/appcontainer/common/src/guarded_capture.rs index 687e5f040..1ba232393 100644 --- a/src/backends/appcontainer/common/src/guarded_capture.rs +++ b/src/backends/appcontainer/common/src/guarded_capture.rs @@ -51,7 +51,13 @@ pub trait GuardedCaptureSession: Send { ) -> Result<(), String>; /// Stops the owned WPR trace and securely discards its raw ETL without - /// analysis. Used when job attachment or sandbox launch fails. + /// 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 diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index b6347c5c7..894858858 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -81,9 +81,27 @@ impl GuardedCaptureSession for PlmGuardedCaptureSession { } fn discard(&mut self) -> Result<(), String> { - self.session - .discard() - .map_err(|e| format!("guarded WPR discard failed: {e:#}")) + let discard_error = match self.session.discard() { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + loop { + match self.session.cancel() { + Ok(()) => { + return Err(format!( + "guarded WPR discard failed: {discard_error:#}; \ + guardian termination was confirmed after abandoning the session" + )); + } + Err(error) => { + eprintln!( + "[mxc] guarded WPR guardian termination remains unconfirmed after \ + discard failure; sandbox enforcement is still active: {error:#}" + ); + } + } + } } fn stop_analyzed(&mut self) -> Result { diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index 958895ef8..977dd23ad 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -1128,18 +1128,20 @@ impl GuardedSession { } pub fn cancel(&mut self) -> Result<()> { - if self.disarmed { - return Ok(()); + let abandoned = !self.disarmed; + if abandoned { + self.pipe.take(); + self.disarmed = true; } - self.pipe.take(); - self.disarmed = true; let exit_code = wait_for_child_termination(self.process.0, WAIT_TIMEOUT_DURATION).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 abandoned { + 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" + ); + } Ok(()) } @@ -2822,6 +2824,32 @@ mod tests { } } + #[test] + fn cancel_confirms_guardian_exit_even_after_session_is_disarmed() { + 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, + }; + + session.cancel().unwrap(); + + assert!(child.try_wait().unwrap().is_some()); + } + #[test] fn guarded_start_command_contains_no_filesystem_path_argument() { let pipe = r"\\.\pipe\mxc-plm-elevated-00112233445566778899aabbccddeeff"; From 5f46d4244fde4e282a23832a0e317a22946b1b18 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 13 Aug 2026 16:30:37 -0700 Subject: [PATCH 45/50] Harden guarded release confirmation retries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/core/mxc_engine/src/guarded_capture.rs | 78 +++++++++++++++++----- src/host/plm/src/elevated.rs | 13 +++- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index 894858858..6ca29b96a 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -21,6 +21,26 @@ use windows::Win32::System::LibraryLoader::{ GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, }; +const GUARDIAN_CONFIRM_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); + +fn confirm_guardian_release_after_discard_failure( + discard_error: String, + mut confirm_release: impl FnMut() -> Result<(), String>, + mut on_retry: impl FnMut(&str), +) -> Result<(), String> { + loop { + match confirm_release() { + Ok(()) => { + return Err(format!( + "guarded WPR discard failed: {discard_error}; guardian termination was \ + confirmed by the cleanup fallback" + )); + } + Err(error) => on_retry(&error), + } + } +} + /// Resolve `plm.exe` next to the module containing `mxc_engine`. /// /// This is the executor directory for `wxc-exec.exe` and the native runtime @@ -86,22 +106,17 @@ impl GuardedCaptureSession for PlmGuardedCaptureSession { Err(error) => error, }; - loop { - match self.session.cancel() { - Ok(()) => { - return Err(format!( - "guarded WPR discard failed: {discard_error:#}; \ - guardian termination was confirmed after abandoning the session" - )); - } - Err(error) => { - eprintln!( - "[mxc] guarded WPR guardian termination remains unconfirmed after \ - discard failure; sandbox enforcement is still active: {error:#}" - ); - } - } - } + confirm_guardian_release_after_discard_failure( + format!("{discard_error:#}"), + || self.session.cancel().map_err(|error| format!("{error:#}")), + |error| { + eprintln!( + "[mxc] guarded WPR guardian termination remains unconfirmed after \ + discard failure; sandbox enforcement is still active: {error}" + ); + std::thread::sleep(GUARDIAN_CONFIRM_RETRY_DELAY); + }, + ) } fn stop_analyzed(&mut self) -> Result { @@ -195,4 +210,35 @@ mod tests { 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 error = confirm_guardian_release_after_discard_failure( + "discard protocol failed".to_string(), + || { + attempts += 1; + if attempts < 3 { + Err(format!("confirmation attempt {attempts} failed")) + } else { + Ok(()) + } + }, + |error| retries.push(error.to_string()), + ) + .unwrap_err(); + + assert_eq!(attempts, 3); + assert_eq!( + retries, + [ + "confirmation attempt 1 failed", + "confirmation attempt 2 failed" + ] + ); + assert!(error.contains("discard protocol failed")); + assert!(error.contains("guardian termination was confirmed")); + } } diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index 977dd23ad..bb19e5c0c 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -1073,6 +1073,7 @@ pub struct GuardedSession { pipe: Option, process: OwnedHandle, disarmed: bool, + abandonment_report_pending: bool, } // SAFETY: `GuardedSession` only holds a Windows process HANDLE (via @@ -1088,6 +1089,10 @@ impl std::fmt::Debug for GuardedSession { .debug_struct("GuardedSession") .field("connected", &self.pipe.is_some()) .field("disarmed", &self.disarmed) + .field( + "abandonment_report_pending", + &self.abandonment_report_pending, + ) .finish_non_exhaustive() } } @@ -1132,15 +1137,17 @@ impl GuardedSession { if abandoned { self.pipe.take(); self.disarmed = true; + self.abandonment_report_pending = true; } let exit_code = wait_for_child_termination(self.process.0, WAIT_TIMEOUT_DURATION).context( "guarded PLM session could not confirm guardian termination after abandoning WPR state", )?; - if abandoned { + 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(()) } @@ -1396,6 +1403,7 @@ pub fn start_guarded_session_with_executable( pipe: Some(pipe), process, disarmed: false, + abandonment_report_pending: false, }) } @@ -2816,10 +2824,12 @@ 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)); } } @@ -2843,6 +2853,7 @@ mod tests { pipe: None, process: OwnedHandle(handle), disarmed: true, + abandonment_report_pending: false, }; session.cancel().unwrap(); From 2463a29982a32be3102e00e7d28f1c7ba745f81a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 13 Aug 2026 16:42:16 -0700 Subject: [PATCH 46/50] Bound guarded release confirmation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- .../common/src/appcontainer_runner.rs | 4 +- .../common/src/base_container_runner.rs | 4 +- .../common/src/guarded_capture.rs | 56 ++++++++++++++ src/core/mxc_engine/src/guarded_capture.rs | 75 +++++++++++++------ src/host/plm/src/elevated.rs | 5 +- 5 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/backends/appcontainer/common/src/appcontainer_runner.rs b/src/backends/appcontainer/common/src/appcontainer_runner.rs index f46db0af7..e2f3400c5 100644 --- a/src/backends/appcontainer/common/src/appcontainer_runner.rs +++ b/src/backends/appcontainer/common/src/appcontainer_runner.rs @@ -1695,13 +1695,13 @@ impl AppContainerSandboxProcess { } fn release_guarded_capture_after_termination_failure(&mut self) { - let Some(mut session) = self.capture_session.take() else { + 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) = session.discard() { + 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}" )); diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index ae5574d34..72541d3b8 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -2716,13 +2716,13 @@ impl BaseContainerSandboxProcess { } fn release_guarded_capture_after_termination_failure(&mut self) { - let Some(mut session) = self.guarded_capture_session.take() else { + 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) = session.discard() { + 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}" )); diff --git a/src/backends/appcontainer/common/src/guarded_capture.rs b/src/backends/appcontainer/common/src/guarded_capture.rs index 1ba232393..fd8d0a905 100644 --- a/src/backends/appcontainer/common/src/guarded_capture.rs +++ b/src/backends/appcontainer/common/src/guarded_capture.rs @@ -94,6 +94,12 @@ pub trait GuardedCaptureFactory: Send + Sync { 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::*; @@ -169,4 +175,54 @@ mod tests { }; 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/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index 6ca29b96a..dc5079577 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -22,23 +22,20 @@ use windows::Win32::System::LibraryLoader::{ }; const GUARDIAN_CONFIRM_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); +const MAX_GUARDIAN_CONFIRM_ATTEMPTS: usize = 3; fn confirm_guardian_release_after_discard_failure( - discard_error: String, mut confirm_release: impl FnMut() -> Result<(), String>, - mut on_retry: impl FnMut(&str), + mut on_retry: impl FnMut(usize, &str), ) -> Result<(), String> { - loop { + for attempt in 1..=MAX_GUARDIAN_CONFIRM_ATTEMPTS { match confirm_release() { - Ok(()) => { - return Err(format!( - "guarded WPR discard failed: {discard_error}; guardian termination was \ - confirmed by the cleanup fallback" - )); - } - Err(error) => on_retry(&error), + Ok(()) => return Ok(()), + Err(error) if attempt < MAX_GUARDIAN_CONFIRM_ATTEMPTS => on_retry(attempt, &error), + Err(error) => return Err(error), } } + unreachable!("guardian confirmation attempt range is non-empty") } /// Resolve `plm.exe` next to the module containing `mxc_engine`. @@ -106,17 +103,30 @@ impl GuardedCaptureSession for PlmGuardedCaptureSession { Err(error) => error, }; - confirm_guardian_release_after_discard_failure( - format!("{discard_error:#}"), + match confirm_guardian_release_after_discard_failure( || self.session.cancel().map_err(|error| format!("{error:#}")), - |error| { + |attempt, error| { eprintln!( "[mxc] guarded WPR guardian termination remains unconfirmed after \ - discard failure; sandbox enforcement is still active: {error}" + discard failure (attempt {attempt}/{MAX_GUARDIAN_CONFIRM_ATTEMPTS}); \ + sandbox enforcement is still active: {error}" ); std::thread::sleep(GUARDIAN_CONFIRM_RETRY_DELAY); }, - ) + ) { + 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 { @@ -216,8 +226,7 @@ mod tests { let mut attempts = 0; let mut retries = Vec::new(); - let error = confirm_guardian_release_after_discard_failure( - "discard protocol failed".to_string(), + confirm_guardian_release_after_discard_failure( || { attempts += 1; if attempts < 3 { @@ -226,19 +235,39 @@ mod tests { Ok(()) } }, - |error| retries.push(error.to_string()), + |attempt, error| retries.push((attempt, error.to_string())), ) - .unwrap_err(); + .unwrap(); assert_eq!(attempts, 3); assert_eq!( retries, [ - "confirmation attempt 1 failed", - "confirmation attempt 2 failed" + (1, "confirmation attempt 1 failed".to_string()), + (2, "confirmation attempt 2 failed".to_string()) ] ); - assert!(error.contains("discard protocol failed")); - assert!(error.contains("guardian termination was confirmed")); + } + + #[test] + fn discard_failure_stops_after_bounded_confirmation_attempts() { + let mut attempts = 0; + let mut retries = Vec::new(); + + let error = confirm_guardian_release_after_discard_failure( + || { + attempts += 1; + Err(format!("confirmation attempt {attempts} failed")) + }, + |attempt, error| retries.push((attempt, error.to_string())), + ) + .unwrap_err(); + + assert_eq!(attempts, MAX_GUARDIAN_CONFIRM_ATTEMPTS); + assert_eq!(retries.len(), MAX_GUARDIAN_CONFIRM_ATTEMPTS - 1); + assert_eq!( + error, + format!("confirmation attempt {MAX_GUARDIAN_CONFIRM_ATTEMPTS} failed") + ); } } diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index bb19e5c0c..ebc433931 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -2835,7 +2835,7 @@ mod tests { } #[test] - fn cancel_confirms_guardian_exit_even_after_session_is_disarmed() { + 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() @@ -2853,12 +2853,13 @@ mod tests { pipe: None, process: OwnedHandle(handle), disarmed: true, - abandonment_report_pending: false, + abandonment_report_pending: true, }; session.cancel().unwrap(); assert!(child.try_wait().unwrap().is_some()); + assert!(!session.abandonment_report_pending); } #[test] From a1f067bcf2f3d4dfbf76052796ecd003780f1446 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 14 Aug 2026 16:19:03 -0700 Subject: [PATCH 47/50] Address guarded capture review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- .../common/src/appcontainer_runner.rs | 229 ++++++++-- .../common/src/base_container_runner.rs | 315 +++++++------- .../appcontainer/common/src/capture_output.rs | 54 ++- .../appcontainer/common/src/dispatcher.rs | 63 ++- .../appcontainer/common/src/job_object.rs | 108 ++++- .../learning_mode/windows/src/etl_decode.rs | 79 +++- src/core/mxc_engine/src/guarded_capture.rs | 140 +++++- src/host/plm/readme.md | 53 +++ src/host/plm/src/elevated.rs | 404 +++++++++++++++++- 9 files changed, 1204 insertions(+), 241 deletions(-) diff --git a/src/backends/appcontainer/common/src/appcontainer_runner.rs b/src/backends/appcontainer/common/src/appcontainer_runner.rs index e2f3400c5..d2c435674 100644 --- a/src/backends/appcontainer/common/src/appcontainer_runner.rs +++ b/src/backends/appcontainer/common/src/appcontainer_runner.rs @@ -1143,31 +1143,18 @@ impl AppContainerScriptRunner { } }; match factory.start(std::process::id()) { - Ok(mut session) => { - if let Err(attach_error) = session.attach_process_tree( + Ok(session) => { + let session = attach_guarded_capture_or_cleanup( + session, job.handle_value(), process_handle.get().0 as usize, - ) { - let termination_error = job.terminate_and_wait(u32::MAX).err(); - 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}" - )); - } - return Err(WxcError::Process(message)); - } + || { + 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() @@ -1307,6 +1294,44 @@ impl SpawnedChild { } } +/// 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 { fn default() -> Self { Self::new() @@ -1755,9 +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_and_wait(u32::MAX) - .map_err(|error| std::io::Error::other(error.to_string())) + 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 { @@ -2175,6 +2218,140 @@ mod tests { 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(); diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs index 72541d3b8..67f9f4d69 100644 --- a/src/backends/appcontainer/common/src/base_container_runner.rs +++ b/src/backends/appcontainer/common/src/base_container_runner.rs @@ -475,6 +475,65 @@ impl BaseContainerRunner { 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`. /// @@ -847,30 +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 { if request.policy.capture_denials.is_some() { - #[cfg(test)] - let native_capture_usable = std::env::var("MXC_FORCE_NATIVE_CAPTURE_USABLE") - .map_or_else( - |_| { - self.process_security_environment_usable() - && self.capture_support.check_apis(true).is_ok() - }, - |forced| forced == "1", - ); - #[cfg(not(test))] - let native_capture_usable = self.process_security_environment_usable() - && self.capture_support.check_apis(true).is_ok(); - - return native_capture_usable - && Self::psec_policy_compatible( - request, - request.policy.denied_paths.is_empty() - || self.capture_support.supports_deny_paths().unwrap_or(false), - ); + return Self::native_capture_eligible( + request, + self.process_security_environment_usable(), + self.capture_support.as_ref(), + ); } - let supports_deny_paths = request.policy.capture_denials.is_some() - || request.policy.denied_paths.is_empty() + 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, @@ -935,27 +1007,11 @@ impl BaseContainerRunner { } pub(crate) fn uses_native_capture_for_request(request: &ExecutionRequest) -> bool { - #[cfg(test)] - let native_capture_usable = std::env::var("MXC_FORCE_NATIVE_CAPTURE_USABLE").map_or_else( - |_| { - Self::is_process_security_environment_usable() - && RealCapturePlatformSupport.check_apis(true).is_ok() - }, - |forced| forced == "1", - ); - #[cfg(not(test))] - let native_capture_usable = Self::is_process_security_environment_usable() - && RealCapturePlatformSupport.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() - || RealCapturePlatformSupport - .supports_deny_paths() - .unwrap_or(false), - ) + Self::native_capture_eligible( + request, + Self::is_process_security_environment_usable(), + &RealCapturePlatformSupport, + ) } fn build_process_security_environment_spec(request: &ExecutionRequest) -> Vec { @@ -2083,42 +2139,22 @@ impl BaseContainerRunner { if let Err(attach_error) = session.attach_process_tree(job.handle_value(), pi.hProcess.0 as usize) { - let termination_error = job.terminate_and_wait(u32::MAX).err(); - let discard_error = session.discard().err(); - unsafe { - let _ = CloseHandle(pi.hProcess); - let _ = CloseHandle(pi.hThread); - } - if termination_error.is_none() { - if legacy_destroy_on_exit { - run_sandbox_cleanup( - &identity, - &sid_string, - request.policy.network_proxy.is_enabled(), - logger, - ); - sandbox_tracking::unregister_ctrl_c_cleanup(); - } - self.proxy_coordinator.stop(logger); - } - let mut message = format!( - "captureDenials failed to attach the sandbox process tree to guarded WPR \ - before resuming the sandbox: {attach_error}" + 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, ); - if let Some(terminate_error) = termination_error { - let _ = write!( - message, - "; additionally failed to terminate the suspended sandbox: \ - {terminate_error}" - ); - } - if let Some(discard_error) = discard_error { - let _ = write!( - message, - "; additionally failed to stop and discard guarded WPR: \ - {discard_error}" - ); - } return Err(ScriptResponse { failure_phase: FailurePhase::LaunchFailed, ..ScriptResponse::error(&message) @@ -2127,40 +2163,25 @@ impl BaseContainerRunner { Some(session) } Err(error) => { - if let Err(terminate_error) = job.terminate_and_wait(u32::MAX) { - unsafe { - let _ = CloseHandle(pi.hProcess); - let _ = CloseHandle(pi.hThread); - } - return Err(ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&format!( - "captureDenials failed to start guarded WPR before resuming the \ - sandbox: {error}; additionally failed to terminate the suspended \ - sandbox: {terminate_error}" - )) - }); - } - unsafe { - let _ = CloseHandle(pi.hProcess); - let _ = CloseHandle(pi.hThread); - } - if legacy_destroy_on_exit { - run_sandbox_cleanup( - &identity, - &sid_string, - request.policy.network_proxy.is_enabled(), - logger, - ); - sandbox_tracking::unregister_ctrl_c_cleanup(); - } - self.proxy_coordinator.stop(logger); - return Err(ScriptResponse { - failure_phase: FailurePhase::LaunchFailed, - ..ScriptResponse::error(&format!( + 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) }); } } @@ -2190,40 +2211,20 @@ impl BaseContainerRunner { } else { None }; - if let Some(mut message) = resume_error { - let termination_error = job.terminate_and_wait(u32::MAX).err(); - let discard_error = guarded_capture_session - .take() - .and_then(|mut session| session.discard().err()); - unsafe { - let _ = CloseHandle(pi.hProcess); - let _ = CloseHandle(pi.hThread); - } - if termination_error.is_none() { - if legacy_destroy_on_exit { - run_sandbox_cleanup( - &identity, - &sid_string, - request.policy.network_proxy.is_enabled(), - logger, - ); - sandbox_tracking::unregister_ctrl_c_cleanup(); - } - self.proxy_coordinator.stop(logger); - } - if let Some(terminate_error) = termination_error { - let _ = write!( - message, - "; additionally failed to terminate the sandbox process tree: \ - {terminate_error}" - ); - } - if let Some(discard_error) = discard_error { - let _ = write!( - message, - "; additionally failed to stop and discard guarded WPR: {discard_error}" - ); - } + 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) @@ -2731,8 +2732,25 @@ impl BaseContainerSandboxProcess { fn kill_process_tree(&mut self) -> std::io::Result<()> { if let Some(job) = &self.job { - job.terminate_and_wait(u32::MAX) - .map_err(|error| std::io::Error::other(error.to_string()))?; + 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}")))?; @@ -4556,6 +4574,13 @@ mod tests { #[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() diff --git a/src/backends/appcontainer/common/src/capture_output.rs b/src/backends/appcontainer/common/src/capture_output.rs index e09aca047..f81f3e2b0 100644 --- a/src/backends/appcontainer/common/src/capture_output.rs +++ b/src/backends/appcontainer/common/src/capture_output.rs @@ -160,20 +160,41 @@ pub fn remove_internal_capture_file(path: &Path) -> std::io::Result<()> { } } +/// 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 { - 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!( + 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 @@ -190,15 +211,16 @@ pub 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}"), - )), - } + 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 diff --git a/src/backends/appcontainer/common/src/dispatcher.rs b/src/backends/appcontainer/common/src/dispatcher.rs index 0cf0da6a4..02af9bc57 100644 --- a/src/backends/appcontainer/common/src/dispatcher.rs +++ b/src/backends/appcontainer/common/src/dispatcher.rs @@ -376,9 +376,14 @@ fn select_backend_with_fallback( }); } // Only thread the factory into the runner when it will actually be used — - // an AppContainer tier honoring `captureDenials`. This keeps T1/T2-without- - // capture/T3-without-capture identical to their pre-fallback construction. - let capture_factory_for_appcontainer = if request.policy.capture_denials.is_some() { + // 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 @@ -418,14 +423,26 @@ 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. - let runner = with_capture_factory( - AppContainerScriptRunner::with_filesystem_mode_and_sid_string( - FilesystemMode::Bfs, - sid, - ) - .with_external_denied_paths(), - capture_factory_for_appcontainer, + // + // 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) } } @@ -1184,6 +1201,32 @@ 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); diff --git a/src/backends/appcontainer/common/src/job_object.rs b/src/backends/appcontainer/common/src/job_object.rs index bbbd67dd6..8e2404e27 100644 --- a/src/backends/appcontainer/common/src/job_object.rs +++ b/src/backends/appcontainer/common/src/job_object.rs @@ -311,7 +311,20 @@ impl UiJobObject { /// Waits until no processes remain assigned to the job. pub fn wait_for_empty(&self) -> Result<(), WxcError> { - let deadline = Instant::now() + JOB_EMPTY_WAIT_TIMEOUT; + 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` @@ -337,19 +350,60 @@ impl UiJobObject { return Err(WxcError::Process(format!( "timed out after {}ms waiting for sandbox job to become empty; {} process(es) \ remain active", - JOB_EMPTY_WAIT_TIMEOUT.as_millis(), + timeout.as_millis(), accounting.ActiveProcesses ))); } - std::thread::sleep(JOB_EMPTY_POLL_INTERVAL); + 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 { @@ -422,6 +476,54 @@ mod tests { 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/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index c5f6a95fc..bf3715357 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -176,6 +176,15 @@ 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 @@ -492,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 @@ -536,16 +542,30 @@ 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 => { - let Some(filetime) = normalized_filetime(header.TimeStamp, acc) else { - return; - }; + // 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); } @@ -555,8 +575,18 @@ unsafe fn process_event_record(event_record: *mut EVENT_RECORD, acc: &mut Accumu 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), } } @@ -816,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; diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index dc5079577..2bb9c56a3 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -23,15 +23,53 @@ use windows::Win32::System::LibraryLoader::{ 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( - mut confirm_release: impl FnMut() -> Result<(), String>, + 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..=MAX_GUARDIAN_CONFIRM_ATTEMPTS { - match confirm_release() { + for attempt in 1..=policy.max_attempts { + match confirm_release(policy.confirm_timeout) { Ok(()) => return Ok(()), - Err(error) if attempt < MAX_GUARDIAN_CONFIRM_ATTEMPTS => on_retry(attempt, &error), + Err(error) if attempt < policy.max_attempts => { + on_retry(attempt, &error); + sleep(policy.retry_delay); + } Err(error) => return Err(error), } } @@ -44,6 +82,10 @@ fn confirm_guardian_release_after_discard_failure( /// 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 @@ -104,15 +146,20 @@ impl GuardedCaptureSession for PlmGuardedCaptureSession { }; match confirm_guardian_release_after_discard_failure( - || self.session.cancel().map_err(|error| format!("{error:#}")), + 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(GUARDIAN_CONFIRM_RETRY_DELAY); }, + std::thread::sleep, ) { Ok(()) => Err(format!( "guarded WPR discard failed: {discard_error:#}; guardian termination was \ @@ -152,6 +199,18 @@ impl GuardedCaptureFactory for PlmGuardedCaptureFactory { /// 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 disposition (guarded-capture item 10): this PR resolves `plm.exe` via +/// [`plm_exe_path`] as the module directory of the loaded MXC native binary +/// (`wxc-exec.exe` / `mxc_ffi.dll`). **Co-location is a discovery mechanism, not +/// a trust boundary** — it locates the guardian; it does not attest it. This PR +/// does **not** yet enforce runtime integrity of `plm.exe` (no signature or +/// directory-ACL check is performed here), so a writable install/asset +/// directory remains an unaddressed elevation surface: `plm.exe` self-elevates, +/// so a planted binary would run as administrator. Supplying that integrity — +/// packaging and code-signing `plm.exe` alongside those binaries, and verifying +/// it before launch — is required and is owned by #834. Until then this risk is +/// not solved; it is only scoped and deferred. fn start_with_plm_path( plm_path: &std::path::Path, owner_pid: u32, @@ -225,9 +284,11 @@ mod tests { 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")) @@ -236,6 +297,7 @@ mod tests { } }, |attempt, error| retries.push((attempt, error.to_string())), + |delay| sleeps.push(delay), ) .unwrap(); @@ -247,27 +309,89 @@ mod tests { (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/host/plm/readme.md b/src/host/plm/readme.md index ff2fd3f1a..20fa2c8b3 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -119,6 +119,59 @@ 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 (co-location, not trust) + +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 is only a **discovery** mechanism; it is **not** a trust boundary. +This PR does not yet enforce runtime integrity of `plm.exe` (no signature or +directory-ACL check is performed before launch), so a writable install/asset +directory remains an unaddressed elevation surface — `plm.exe` self-elevates, so +a planted binary would run as administrator. Supplying that integrity +(packaging + code-signing `plm.exe` alongside those binaries, and verifying it +before launch) is required and is owned by **#834**. Until then the risk is +scoped and deferred, not solved. + +### 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 ebc433931..65a0ced9f 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -76,6 +76,10 @@ 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 before detaching it, so a failed stop signal can +/// never hang the guardian indefinitely. +const WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(5); const POLL_INTERVAL: Duration = Duration::from_millis(10); const TRANSFER_POLL_INTERVAL: Duration = Duration::from_millis(1); const SW_HIDE: i32 = 0; @@ -568,6 +572,28 @@ struct ProcessTrackerState { 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 { @@ -584,6 +610,9 @@ impl ProcessTrackerState { active_process_zero_filetime: None, error: None, termination_error: None, + attestation_race_count: 0, + first_attestation_race: None, + unattested_active: HashMap::new(), } } @@ -593,6 +622,19 @@ impl ProcessTrackerState { } } + /// 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)>, @@ -620,9 +662,14 @@ impl ProcessTrackerState { let (process, creation_filetime) = match attest() { Ok(attestation) => attestation, Err(error) => { - self.fail(format!( - "failed to attest job process generation for PID {pid}: {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; } }; @@ -668,6 +715,17 @@ impl ProcessTrackerState { { 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}" )); @@ -824,6 +882,25 @@ impl JobProcessTracker { 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")?; @@ -832,9 +909,11 @@ impl JobProcessTracker { if root_exit_filetime == 0 { anyhow::bail!("sandbox root process has no kernel-attested exit time after WPR stop"); } - if root_exit_filetime < self.root_creation_filetime - || root_exit_filetime > completed_filetime - { + 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) @@ -844,8 +923,11 @@ impl JobProcessTracker { format!("failed to attest exit time for job process {}", process.pid) })?; if exit_filetime == 0 - || exit_filetime < process.creation_filetime - || exit_filetime > completed_filetime + || !attested_lifetime_within_bounds( + exit_filetime, + process.creation_filetime, + completed_filetime, + ) { anyhow::bail!( "job process {} has an invalid kernel-attested lifetime", @@ -913,19 +995,93 @@ impl JobProcessTracker { } fn stop_worker(&mut self) { - if let Some(worker) = self.worker.take() { - let _ = unsafe { - PostQueuedCompletionStatus(self.completion_port.0, TRACKER_STOP_MESSAGE, 0, None) - }; - if worker.join().is_err() { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state.fail("guarded WPR job tracker thread panicked"); + let Some(worker) = self.worker.take() else { + return; + }; + // Signal the worker to return from its blocking + // `GetQueuedCompletionStatus`. + let post_result = unsafe { + PostQueuedCompletionStatus(self.completion_port.0, TRACKER_STOP_MESSAGE, 0, None) + }; + if let Err(error) = post_result { + // The stop signal could not be queued, so the worker may still be + // blocked in an INFINITE `GetQueuedCompletionStatus`. Do NOT enter + // the bounded wait below — that would guarantee a full + // `WORKER_JOIN_TIMEOUT` stall for no benefit. Record the failure and + // detach immediately; the completion port closes when `self` drops, + // which unblocks the worker as a fallback. + self.fail_state(format!( + "failed to signal the guarded WPR job tracker to stop: {error}" + )); + return; + } + + // Bounded join: on the stop message the worker returns promptly. If it + // does not, detach rather than hang the guardian indefinitely. + match bounded_join( + || worker.is_finished(), + Instant::now, + std::thread::sleep, + WORKER_JOIN_TIMEOUT, + POLL_INTERVAL, + ) { + BoundedJoinOutcome::TimedOut => { + self.fail_state( + "guarded WPR job tracker did not shut down within the bounded timeout; \ + detaching the worker thread" + .to_string(), + ); + } + BoundedJoinOutcome::Finished => { + 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); + } +} + +/// 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 { @@ -959,6 +1115,22 @@ fn process_times(process: HANDLE) -> Result<(u64, u64)> { 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 { @@ -978,6 +1150,27 @@ 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. fn process_job_notifications(port: HANDLE, job: HANDLE, state: &Arc>) { loop { let mut message = 0u32; @@ -1133,13 +1326,25 @@ impl GuardedSession { } 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, WAIT_TIMEOUT_DURATION).context( + 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", )?; if self.abandonment_report_pending { @@ -2547,6 +2752,167 @@ mod tests { 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 tracker_failure_terminates_the_attested_job() { use windows::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW}; From 8d1dafdc9c2cdea9da7c97ccf777a5dba5138bd9 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 14 Aug 2026 18:08:56 -0700 Subject: [PATCH 48/50] Harden elevated PLM helper trust Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/core/mxc_engine/src/guarded_capture.rs | 22 +- src/host/plm/Cargo.toml | 4 + src/host/plm/readme.md | 69 +- src/host/plm/src/elevated.rs | 243 +++- src/host/plm/src/lib.rs | 3 + src/host/plm/src/main.rs | 5 + src/host/plm/src/trust.rs | 1251 ++++++++++++++++++++ 7 files changed, 1535 insertions(+), 62 deletions(-) create mode 100644 src/host/plm/src/trust.rs diff --git a/src/core/mxc_engine/src/guarded_capture.rs b/src/core/mxc_engine/src/guarded_capture.rs index 2bb9c56a3..2915425fd 100644 --- a/src/core/mxc_engine/src/guarded_capture.rs +++ b/src/core/mxc_engine/src/guarded_capture.rs @@ -200,17 +200,17 @@ impl GuardedCaptureFactory for PlmGuardedCaptureFactory { /// rather than depending on whether `plm.exe` happens to already exist next to /// the current test binary in a given build/CI environment. /// -/// Trust disposition (guarded-capture item 10): this PR resolves `plm.exe` via -/// [`plm_exe_path`] as the module directory of the loaded MXC native binary -/// (`wxc-exec.exe` / `mxc_ffi.dll`). **Co-location is a discovery mechanism, not -/// a trust boundary** — it locates the guardian; it does not attest it. This PR -/// does **not** yet enforce runtime integrity of `plm.exe` (no signature or -/// directory-ACL check is performed here), so a writable install/asset -/// directory remains an unaddressed elevation surface: `plm.exe` self-elevates, -/// so a planted binary would run as administrator. Supplying that integrity — -/// packaging and code-signing `plm.exe` alongside those binaries, and verifying -/// it before launch — is required and is owned by #834. Until then this risk is -/// not solved; it is only scoped and deferred. +/// 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, diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index 8ef810864..29a28012a 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -33,8 +33,12 @@ windows = { workspace = true, features = [ "Win32_System_IO", "Win32_System_Pipes", "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 } diff --git a/src/host/plm/readme.md b/src/host/plm/readme.md index 20fa2c8b3..587f4eeda 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -127,7 +127,7 @@ 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 (co-location, not trust) +### 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 @@ -135,14 +135,65 @@ 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 is only a **discovery** mechanism; it is **not** a trust boundary. -This PR does not yet enforce runtime integrity of `plm.exe` (no signature or -directory-ACL check is performed before launch), so a writable install/asset -directory remains an unaddressed elevation surface — `plm.exe` self-elevates, so -a planted binary would run as administrator. Supplying that integrity -(packaging + code-signing `plm.exe` alongside those binaries, and verifying it -before launch) is required and is owned by **#834**. Until then the risk is -scoped and deferred, not solved. +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 diff --git a/src/host/plm/src/elevated.rs b/src/host/plm/src/elevated.rs index 65a0ced9f..a28f16fe2 100644 --- a/src/host/plm/src/elevated.rs +++ b/src/host/plm/src/elevated.rs @@ -77,9 +77,12 @@ 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 before detaching it, so a failed stop signal can -/// never hang the guardian indefinitely. +/// 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; @@ -825,16 +828,24 @@ impl JobProcessTracker { root_pid, ))); let worker_state = Arc::clone(&state); - let raw_port = completion_port.0 .0 as usize; - let raw_job = job.0 .0 as usize; + // 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( - HANDLE(raw_port as *mut c_void), - HANDLE(raw_job as *mut c_void), - &worker_state, - ); + process_job_notifications(worker_port, worker_job, &worker_state); }) .context("failed to start guarded WPR job tracker")?; Ok(Self { @@ -998,45 +1009,72 @@ impl JobProcessTracker { let Some(worker) = self.worker.take() else { return; }; - // Signal the worker to return from its blocking - // `GetQueuedCompletionStatus`. + // 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 { - // The stop signal could not be queued, so the worker may still be - // blocked in an INFINITE `GetQueuedCompletionStatus`. Do NOT enter - // the bounded wait below — that would guarantee a full - // `WORKER_JOIN_TIMEOUT` stall for no benefit. Record the failure and - // detach immediately; the completion port closes when `self` drops, - // which unblocks the worker as a fallback. 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; } - // Bounded join: on the stop message the worker returns promptly. If it - // does not, detach rather than hang the guardian indefinitely. - match bounded_join( + // 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_JOIN_TIMEOUT, + WORKER_ESCALATION_TIMEOUT, POLL_INTERVAL, - ) { - BoundedJoinOutcome::TimedOut => { - self.fail_state( - "guarded WPR job tracker did not shut down within the bounded timeout; \ - detaching the worker thread" - .to_string(), - ); - } - BoundedJoinOutcome::Finished => { - if worker.join().is_err() { - self.fail_state("guarded WPR job tracker thread panicked".to_string()); - } - } + ); + 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()); } } @@ -1051,6 +1089,19 @@ impl JobProcessTracker { } } +/// 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 { @@ -1106,6 +1157,26 @@ fn duplicate_owner_handle(owner: HANDLE, source_handle: usize) -> Result 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(); @@ -1171,14 +1242,24 @@ fn filetime_value(value: FILETIME) -> u64 { /// 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. -fn process_job_notifications(port: HANDLE, job: HANDLE, state: &Arc>) { +/// +/// 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, + port.0, &mut message, &mut completion_key, &mut overlapped, @@ -1187,7 +1268,7 @@ fn process_job_notifications(port: HANDLE, job: HANDLE, state: &Arc { tracker_state - .process_started(pid, observed_filetime, || attest_job_process(job, pid)); + .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); @@ -1218,7 +1299,7 @@ fn process_job_notifications(port: HANDLE, job: HANDLE, state: &Arc, ) -> 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, @@ -2378,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 { @@ -2913,6 +3011,67 @@ mod tests { 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}; 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..273eaceb7 --- /dev/null +++ b/src/host/plm/src/trust.rs @@ -0,0 +1,1251 @@ +// 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, GetAce, 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, +}; + +/// 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> { + 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 mut entries = Vec::with_capacity(info.AceCount as usize); + for index in 0..info.AceCount { + let mut ace_ptr: *mut c_void = ptr::null_mut(); + // A failure to read any ACE is treated as fail-closed corruption. + unsafe { GetAce(dacl, index, &mut ace_ptr) } + .with_context(|| format!("GetAce({index}) failed"))?; + // SAFETY: `ace_ptr` points to a valid ACE within the DACL. + let header = unsafe { &*(ace_ptr as *const ACE_HEADER) }; + if header.AceFlags & INHERIT_ONLY_ACE != 0 { + // Inherit-only ACEs do not apply to this directory. + continue; + } + let allow = match ace_type_kind(header.AceType) { + Some(kind) => kind, + None => bail!( + "the DACL contains an unsupported ACE type {:#04x} (object/callback/conditional); \ + failing closed because its effect cannot be classified", + header.AceType + ), + }; + // ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE share layout through SidStart. + let ace = ace_ptr as *const ACCESS_ALLOWED_ACE; + let mask = unsafe { (*ace).Mask }; + let sid = PSID(unsafe { &(*ace).SidStart } as *const _ as *mut c_void); + let sid_string = sid_to_string(sid)?; + entries.push(DaclEntry { + sid: sid_string, + mask, + allow, + }); + } + Ok(entries) +} + +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 + ); + } +} From c126863293b252966313256b3a7b48212503e562 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 14 Aug 2026 18:31:51 -0700 Subject: [PATCH 49/50] Validate DACL pointer bounds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/host/plm/src/trust.rs | 64 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/src/host/plm/src/trust.rs b/src/host/plm/src/trust.rs index 273eaceb7..fc832d586 100644 --- a/src/host/plm/src/trust.rs +++ b/src/host/plm/src/trust.rs @@ -763,6 +763,9 @@ fn read_directory_security(dir: &Path) -> Result { /// 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 { @@ -775,14 +778,40 @@ fn parse_dacl(dacl: *const ACL) -> Result> { } .context("GetAclInformation failed")?; + let acl_start = dacl as usize; + 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_end = acl_start + .checked_add(acl_len) + .context("the DACL address range overflowed")?; + let mut entries = Vec::with_capacity(info.AceCount as usize); for index in 0..info.AceCount { let mut ace_ptr: *mut c_void = ptr::null_mut(); // A failure to read any ACE is treated as fail-closed corruption. unsafe { GetAce(dacl, index, &mut ace_ptr) } .with_context(|| format!("GetAce({index}) failed"))?; - // SAFETY: `ace_ptr` points to a valid ACE within the DACL. - let header = unsafe { &*(ace_ptr as *const ACE_HEADER) }; + if ace_ptr.is_null() { + bail!("GetAce({index}) succeeded without returning an ACE pointer"); + } + let ace_start = ace_ptr as usize; + let header_end = ace_start + .checked_add(std::mem::size_of::()) + .context("the ACE header address range overflowed")?; + if ace_start < acl_start || header_end > acl_end { + bail!("ACE {index} has a header outside the DACL bounds"); + } + // SAFETY: the fixed-size header range was proven to lie within the DACL. + let header = unsafe { ptr::read_unaligned(ace_ptr as *const ACE_HEADER) }; + let ace_len = header.AceSize as usize; + let ace_end = ace_start + .checked_add(ace_len) + .context("the ACE address range overflowed")?; + if ace_len < std::mem::size_of::() || ace_end > acl_end { + bail!("ACE {index} has an invalid size ({ace_len} bytes)"); + } if header.AceFlags & INHERIT_ONLY_ACE != 0 { // Inherit-only ACEs do not apply to this directory. continue; @@ -796,9 +825,34 @@ fn parse_dacl(dacl: *const ACL) -> Result> { ), }; // ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE share layout through SidStart. - let ace = ace_ptr as *const ACCESS_ALLOWED_ACE; - let mask = unsafe { (*ace).Mask }; - let sid = PSID(unsafe { &(*ace).SidStart } as *const _ as *mut c_void); + 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"); + } + // SAFETY: the complete mask and fixed SID header lie within the + // validated ACE range. + let mask = unsafe { ptr::read_unaligned((ace_start + mask_offset) as *const u32) }; + let sid_start = (ace_start + sid_offset) as *const u8; + let subauthority_count = unsafe { ptr::read(sid_start.add(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 = PSID(sid_start as *mut c_void); let sid_string = sid_to_string(sid)?; entries.push(DaclEntry { sid: sid_string, From 4a27b4e43cf1758631817bb1a743253d9549dcf7 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Fri, 14 Aug 2026 19:01:28 -0700 Subject: [PATCH 50/50] Parse trusted ACLs from owned buffers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4eff3ee2-323c-494d-99b9-f7b54e495216 --- src/host/plm/Cargo.toml | 1 + src/host/plm/src/trust.rs | 109 +++++++++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 32 deletions(-) diff --git a/src/host/plm/Cargo.toml b/src/host/plm/Cargo.toml index 29a28012a..cb7e01789 100644 --- a/src/host/plm/Cargo.toml +++ b/src/host/plm/Cargo.toml @@ -32,6 +32,7 @@ windows = { workspace = true, features = [ "Win32_System_JobObjects", "Win32_System_IO", "Win32_System_Pipes", + "Win32_System_Diagnostics_Debug", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_UI_Shell", diff --git a/src/host/plm/src/trust.rs b/src/host/plm/src/trust.rs index fc832d586..d7080442e 100644 --- a/src/host/plm/src/trust.rs +++ b/src/host/plm/src/trust.rs @@ -56,7 +56,7 @@ use windows::Win32::Security::WinTrust::{ WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, WTD_UI_NONE, }; use windows::Win32::Security::{ - AclSizeInformation, GetAce, GetAclInformation, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, + AclSizeInformation, GetAclInformation, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, }; @@ -68,6 +68,7 @@ use windows::Win32::Storage::FileSystem::{ 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 @@ -778,50 +779,49 @@ fn parse_dacl(dacl: *const ACL) -> Result> { } .context("GetAclInformation failed")?; - let acl_start = dacl as usize; 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_end = acl_start - .checked_add(acl_len) - .context("the DACL address range overflowed")?; + 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 mut ace_ptr: *mut c_void = ptr::null_mut(); - // A failure to read any ACE is treated as fail-closed corruption. - unsafe { GetAce(dacl, index, &mut ace_ptr) } - .with_context(|| format!("GetAce({index}) failed"))?; - if ace_ptr.is_null() { - bail!("GetAce({index}) succeeded without returning an ACE pointer"); - } - let ace_start = ace_ptr as usize; - let header_end = ace_start + let header_end = ace_offset .checked_add(std::mem::size_of::()) .context("the ACE header address range overflowed")?; - if ace_start < acl_start || header_end > acl_end { - bail!("ACE {index} has a header outside the DACL bounds"); - } - // SAFETY: the fixed-size header range was proven to lie within the DACL. - let header = unsafe { ptr::read_unaligned(ace_ptr as *const ACE_HEADER) }; - let ace_len = header.AceSize as usize; - let ace_end = ace_start + 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_end { + if ace_len < std::mem::size_of::() || ace_end > acl_bytes.len() { bail!("ACE {index} has an invalid size ({ace_len} bytes)"); } - if header.AceFlags & INHERIT_ONLY_ACE != 0 { + 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(header.AceType) { + 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", - header.AceType + ace_type ), }; // ACCESS_ALLOWED_ACE and ACCESS_DENIED_ACE share layout through SidStart. @@ -834,11 +834,16 @@ fn parse_dacl(dacl: *const ACL) -> Result> { if ace_len < minimum_len { bail!("ACE {index} is too short to contain a SID"); } - // SAFETY: the complete mask and fixed SID header lie within the - // validated ACE range. - let mask = unsafe { ptr::read_unaligned((ace_start + mask_offset) as *const u32) }; - let sid_start = (ace_start + sid_offset) as *const u8; - let subauthority_count = unsafe { ptr::read(sid_start.add(1)) } as usize; + 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 @@ -852,8 +857,10 @@ fn parse_dacl(dacl: *const ACL) -> Result> { { bail!("ACE {index} contains a SID that extends beyond the ACE bounds"); } - let sid = PSID(sid_start as *mut c_void); - let sid_string = sid_to_string(sid)?; + 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, @@ -863,6 +870,44 @@ fn parse_dacl(dacl: *const ACL) -> Result> { 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) {