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/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 2f2850e5f..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 @@ -323,7 +374,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 +393,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 +415,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 @@ -484,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( @@ -790,7 +868,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 +898,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/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/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/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..1083e0d21 100644 --- a/src/host/plm/readme.md +++ b/src/host/plm/readme.md @@ -1,101 +1,103 @@ -# 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. - -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. - -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 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. - -> **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/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/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 ] [--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. - -### `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`. -- **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. - -## 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/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..98babc648 --- /dev/null +++ b/src/host/plm/src/analysis.rs @@ -0,0 +1,243 @@ +// 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], + current_directory: Option<&str>, +) -> (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) + || is_current_directory_path(&denial.resource, current_directory) + { + 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 => {} + } + } + + 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) +} + +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, None); + 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 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(); + 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..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; -pub mod access_failure; -pub mod config; -pub mod coordination; -pub mod event_parser; -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 32efce25e..121b284a2 100644 --- a/src/host/plm/src/log.rs +++ b/src/host/plm/src/log.rs @@ -1,114 +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 serde_json::{json, Value}; -use std::io::{self, BufRead, Write}; -use std::path::{Path, PathBuf}; - -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; - -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(()) -} - -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 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); - - // Clean up the temp .etl regardless of parse outcome. - let _ = std::fs::remove_file(&trace_file); - - let parse = parse?; - - // 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, - &parse.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(()) -} +// 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 17bf4b06a..71db66a2c 100644 --- a/src/host/plm/src/main.rs +++ b/src/host/plm/src/main.rs @@ -1,511 +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, - /// 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, - verbose_logging, - } => { - let _singleton = acquire_singleton_if_needed()?; - stop::run( - stop::StopOptions { - log_dir, - bin_path, - config_path, - trace_file, - verbose: verbose_logging, - }, - &exe, - ) - } - 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 + } + } +} 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..cdb368438 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,43 @@ 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 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 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 +317,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,25 +340,95 @@ 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 `.`/`..`. 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 { - match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { - (Ok(ca), Ok(cb)) => ca == cb, - _ => a == 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 { + 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 + .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); + let key = normalize_win32_components(&key, !is_verbatim); + key.to_ascii_lowercase() +} + +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())) + .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); + if trim_trailing_dots_and_spaces { + component.trim_end_matches([' ', '.']) + } else { + component + } + } + }) + .collect::>() + .join("\\") +} + #[cfg(test)] mod tests { use super::*; @@ -355,6 +532,93 @@ 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 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"); + 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 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 @@ -375,4 +639,88 @@ 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())); + 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); + } + + #[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); + } + + #[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") + )); + } }