diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e2b9683e..fed9be7f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -191,7 +191,7 @@ The workspace is organized into six top-level directories under `src/`: | Directory | Purpose | Examples | |-----------|---------|----------| -| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `generated/` | +| `core/` | Cross-platform foundation + per-platform aggregator binaries | `wxc_common/`, `wxc/`, `lxc/`, `mxc_darwin/`, `mxc_engine/`, `mxc-sdk/`, `mxc_pty/`, `mxc_build_common/`, `learning_mode_core/`, `generated/` | | `backends/` | Backend-specific code (one subfolder per containment backend or backend support component) | `appcontainer/common`, `windows_sandbox/{daemon,guest,common,lifecycle}`, `isolation_session/{bindings,common}`, `learning_mode/windows`, `hyperlight/common`, `nanvix/{common,build_common,binaries,runner}`, `lxc/common`, `bubblewrap/common`, `wslc/common`, `seatbelt/common` | | `ffi/` | Foreign-function-interface crates (C ABI for language bindings) | `mxc_ffi/` | | `host/` | Host-side utilities | `wxc_host_prep/`, `wxc_winhttp_proxy_shim/` | @@ -200,7 +200,8 @@ The workspace is organized into six top-level directories under `src/`: - `wxc_common` is the **cross-platform foundation**: config parsing, models, errors, logger, `ScriptRunner` / `StatefulSandboxBackend` traits, state-aware dispatch helpers, validators, ids, ui-policy, encoding. Plus a few thin Windows API helpers shared by host tools and backends (`process_util`, `string_util`, `filesystem_dacl`, `diagnostic`). It must not depend on any `backends/*` crate. - Each Windows containment backend lives in its own `backends/*/common` crate (e.g. `appcontainer_common`, `windows_sandbox_common`, `isolation_session_common`, `hyperlight_common`, `nanvix_runner`). Backend crates depend on `wxc_common`; there are no cross-edges between backend crates. Windows Sandbox additionally has `windows_sandbox_lifecycle`, which owns the one-shot and state-aware runners and depends on `windows_sandbox_common` for the wire protocol, plus separate daemon and guest binaries. -- `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, and depends only on `wxc_common`; runner integration consumes it from the AppContainer backend layer. +- `learning_mode_core` is the cross-platform learning-mode denial model and output layer. It owns denial types, summaries, analyzer abstractions, and RFC 7464 emission, and must not depend on any `backends/*` crate. +- `learning_mode_windows` (`backends/learning_mode/windows`) is a Windows-only backend support crate for the AppInfo-brokered Learning Mode APIs in `processmodel.dll`. It runtime-resolves the Learning Mode trace and process security-environment exports, owns their typed handle/lifecycle wrappers, decodes sealed ETL traces through `learning_mode_core`, and depends on `wxc_common` plus `learning_mode_core`; runner integration consumes it from the AppContainer backend layer. - `wxc`, `lxc`, and `mxc_darwin` are thin binary crates (`wxc-exec` / `lxc-exec` / `mxc-exec-mac`) that wire up CLI args (`clap`), load/validate config, handle maintenance modes (`--probe`, `--delete`, `--setup-*`, `--audit`), and **delegate all backend dispatch to `mxc_engine`**. They contain no `match request.containment` of their own. `wxc-exec` additionally owns the Windows Ctrl-C / DACL-cleanup / `--audit` PLM-trace / telemetry orchestration around the engine call. - `mxc_engine` is the **single execution engine** — the one home for "given an `ExecutionRequest`, run it". It owns: run-to-completion backend selection (`run` / `resolve_runner`, covering **all** backends, incl. the Windows ProcessContainer BaseContainer/AppContainer BFS/DACL fallback tiers via `appcontainer_common::dispatcher::dispatch_with_fallback`, and every experimental backend, feature-gated); streaming (`spawn` → `Box`); state-aware lifecycle dispatch (`run_state_aware`, including Windows Sandbox and IsolationSession); host probing (`platform_support` / `PlatformSupport`); and config building (`build_request`, `SandboxPolicy` + sections, `available_tools_policy`/`user_profile_policy`/`temporary_files_policy`). It depends on the backend crates (cfg-split: appcontainer/windows_sandbox lifecycle/isolation_session/wslc/nanvix on Windows, bubblewrap/lxc/nanvix on Linux, seatbelt on macOS) so it can't live in `wxc_common`. Both the executor binaries and `mxc-sdk` call into it. `ResolvedRunner` carries the boxed runner plus (Windows only) the optional `DaclManager` guard, so `wxc-exec` can park the guard for its signal handler. - `mxc-sdk` is the **public Rust SDK** — a thin facade over `mxc_engine`. Build a `SandboxRequest` with `build_request`, then either `run(request)` (run-to-completion; returns an `Output` with the `WaitOutcome` + captured `stdout`/`stderr`) or `spawn_sandbox(request)` (returns a `Sandbox` handle for live bidirectional stdio — `take_stdin`/`take_stdout`/`take_stderr`, `kill()`, `wait()` returning a `WaitOutcome` (`Exited(i32)` / `TimedOut`) as `io::Result`, or `wait_with_output()`). It re-exports the engine's config-building surface (`build_request`, `mxc_sdk::policy::{SandboxPolicy sections}`, discovery helpers) and `platform_support`; `mod sandbox` (wrapping the engine's `SandboxProcess` in `Sandbox`) is its only local module. No pty is ever allocated. Streaming supports Seatbelt (macOS), Bubblewrap (Linux), and Windows ProcessContainer (AppContainer + BaseContainer); other backends return `ErrorCode::UnsupportedContainment`. @@ -214,7 +215,7 @@ The workspace is organized into six top-level directories under `src/`: ### Config parser pattern -The parser deserializes JSON directly into the typed wire model (`wxc_common::wire`), the single source of truth for the config shape (it also generates the JSON schema). All typed config deserialization goes through `config_deserialize.rs`, which distinguishes syntax errors from typed policy errors and adds the complete JSON path plus source line/column when available; state-aware backend errors are prefixed with their full `experimental..` location. `config_parser.rs` then maps the wire types to the validated domain structs in `models.rs`. The stable surface uses `deny_unknown_fields` (closed); the `experimental` block is permissive. +The parser deserializes JSON directly into the typed wire model (`wxc_common::wire`), the single source of truth for the config shape (it also generates the JSON schema). All typed config deserialization goes through `config_deserialize.rs`, which distinguishes syntax errors from typed policy errors and adds the complete JSON path plus source line/column when available; state-aware backend errors are prefixed with their full `experimental..` location. `config_parser.rs` then maps the wire types to the validated domain structs in `models.rs`. The stable surface uses `deny_unknown_fields` (closed); the `experimental` block is permissive. ### TypeScript conventions diff --git a/src/Cargo.lock b/src/Cargo.lock index 82b872c1..b8c59732 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1265,11 +1265,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "learning_mode_core" +version = "0.7.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "learning_mode_windows" version = "0.7.0" dependencies = [ "flatbuffers", + "learning_mode_core", "sandbox_spec", "thiserror", "windows", diff --git a/src/Cargo.toml b/src/Cargo.toml index e73749b5..4a9f7e2c 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -2,6 +2,7 @@ members = [ "core/wxc", "core/wxc_common", + "core/learning_mode_core", "core/lxc", "core/mxc_darwin", "core/mxc-sdk", @@ -113,6 +114,7 @@ windows_sandbox_lifecycle = { path = "backends/windows_sandbox/lifecycle" } isolation_session_common = { path = "backends/isolation_session/common" } hyperlight_common = { path = "backends/hyperlight/common" } learning_mode_windows = { path = "backends/learning_mode/windows" } +learning_mode_core = { path = "core/learning_mode_core" } nanvix_runner = { path = "backends/nanvix/runner" } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } diff --git a/src/backends/learning_mode/windows/Cargo.toml b/src/backends/learning_mode/windows/Cargo.toml index d99f4866..515448a3 100644 --- a/src/backends/learning_mode/windows/Cargo.toml +++ b/src/backends/learning_mode/windows/Cargo.toml @@ -9,6 +9,7 @@ thiserror = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] wxc_common = { workspace = true } +learning_mode_core = { workspace = true } windows = { workspace = true } windows-core = { workspace = true } diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs new file mode 100644 index 00000000..51161e8b --- /dev/null +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Decode a sealed learning-mode `.etl` into the captureDenials RFC 7464 +//! JSON text sequence, or dump its raw ETW events for schema discovery. +//! +//! This is a developer diagnostic for inspecting captured traces manually. +//! End users and SDK agents do not invoke it: the BaseContainer runner seals +//! the trace and reports its path, while this example performs the manual +//! analysis until runner integration consumes the trace automatically. +//! +//! Usage: +//! +//! ```text +//! # Emit the DeniedResource JSON text sequence (0x1E-framed) to stdout: +//! cargo run -p learning_mode_windows --example lm_analyze -- --exit-code +//! +//! # Dump every decoded event (id + property name/value pairs): +//! cargo run -p learning_mode_windows --example lm_analyze -- --raw +//! ``` +//! +//! Exit codes: `0` = decoded; `2` = wrong platform / bad args; `1` = decode +//! failed. + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("lm_analyze is Windows-only"); + std::process::exit(2); +} + +#[cfg(target_os = "windows")] +fn main() { + std::process::exit(windows_impl::run()); +} + +#[cfg(target_os = "windows")] +mod windows_impl { + use std::collections::BTreeMap; + use std::io::Write; + use std::path::Path; + + use learning_mode_core::{emit, DenialAnalyzer, DenialSummary}; + use learning_mode_windows::{visit_raw_events, EtlDenialAnalyzer}; + + pub fn run() -> i32 { + let args: Vec = std::env::args().skip(1).collect(); + let Some(etl_path) = args.first() else { + eprintln!("usage: lm_analyze [--raw | --exit-code ]"); + return 2; + }; + let raw = args.iter().any(|a| a == "--raw"); + let path = Path::new(etl_path); + + if raw { + dump_raw(path) + } else { + let Some(exit_code) = parse_exit_code(&args) else { + eprintln!("--exit-code is required unless --raw is used"); + return 2; + }; + emit_json_sequence(path, exit_code) + } + } + + fn parse_exit_code(args: &[String]) -> Option { + let index = args.iter().position(|arg| arg == "--exit-code")?; + args.get(index + 1)?.parse().ok() + } + + /// Decodes denials and writes the RFC 7464 JSON text sequence to stdout. + fn emit_json_sequence(path: &Path, exit_code: i32) -> i32 { + let analysis = match EtlDenialAnalyzer.analyze(path) { + Ok(d) => d, + Err(e) => { + eprintln!("analyze failed: {e}"); + return 1; + } + }; + let summary = DenialSummary::new( + exit_code, + analysis.denials.len(), + analysis.denied_resources_truncated, + ); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + if let Err(e) = emit::write_stream(&mut handle, &analysis.denials, &summary) { + eprintln!("write failed: {e}"); + return 1; + } + eprintln!( + "lm_analyze: {} unique denial(s){}", + analysis.denials.len(), + if analysis.denied_resources_truncated { + " (truncated)" + } else { + "" + } + ); + 0 + } + + /// Dumps every decoded event, plus a per-event-id histogram, so the + /// real provider/ID/field schema can be confirmed against hardware. + fn dump_raw(path: &Path) -> i32 { + let mut histogram: BTreeMap<(String, u16), usize> = BTreeMap::new(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + let event_count = { + let mut visitor = |ev: &learning_mode_windows::DecodedEventParts| { + let provider = format!("{:?}", ev.provider); + *histogram + .entry((provider.clone(), ev.event_id)) + .or_default() += 1; + let props: Vec = ev.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); + writeln!( + out, + "provider {} event {} | {}", + provider, + ev.event_id, + props.join(" | ") + ) + }; + match visit_raw_events(path, &mut visitor) { + Ok(count) => count, + Err(e) => { + eprintln!("decode failed: {e}"); + return 1; + } + } + }; + + if writeln!(out, "--- {event_count} event(s) total ---").is_err() { + return 1; + } + for ((provider, id), count) in &histogram { + if writeln!(out, " provider {provider:?} id {id}: {count}").is_err() { + return 1; + } + } + 0 + } +} diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs new file mode 100644 index 00000000..1ab062af --- /dev/null +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Sealed-ETL decoder: turns the `.etl` that [`crate::CaptureSession::finish`] +//! produces into cross-platform [`DeniedResource`]s. +//! +//! The trace is opened in **file mode** (`EVENT_TRACE_LOGFILEW.LogFileName`, +//! without `PROCESS_TRACE_MODE_REAL_TIME`). `ProcessTrace` walks every +//! buffered event and returns on its own at end-of-file, so there is no +//! controller session to stop and no worker thread to join — we run it +//! synchronously and extract/de-duplicate denials inside the callback so large +//! traces do not accumulate every decoded event in memory. +//! +//! [`EtlDenialAnalyzer`] implements the cross-platform +//! [`learning_mode_core::DenialAnalyzer`] trait so the runner and tests can +//! depend on the abstraction rather than this Windows-specific decoder. +//! +//! The diagnostic console has a separate real-time, display-oriented ETW +//! consumer in `tools/mxc_diagnostic_console`. It is a binary-private module +//! that owns trace sessions and channels arbitrary provider events to a UI. +//! This backend instead reads sealed files synchronously, filters a fixed +//! provider/event vocabulary, bounds results, and propagates target decode +//! failures. Depending on the tool would invert the workspace dependency +//! direction; shared generic TDH primitives can be extracted later if another +//! runtime consumer needs them. + +use std::collections::HashSet; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; + +use learning_mode_core::{AnalysisResult, AnalyzeError, DenialAnalyzer, DeniedResource}; +use windows::core::PWSTR; +use windows::Win32::System::Diagnostics::Etw::{ + CloseTrace, OpenTraceW, ProcessTrace, EVENT_RECORD, EVENT_TRACE_LOGFILEW, + PROCESS_TRACE_MODE_EVENT_RECORD, +}; + +use crate::extractors::{extract_denial, is_learning_mode_event, DecodedEventParts, RawDenial}; +use crate::{path_norm, tdh_decode}; + +/// `OpenTraceW` returns this sentinel (`(TRACEHANDLE)-1`) on failure. +const INVALID_PROCESSTRACE_HANDLE: u64 = u64::MAX; +const MAX_UNIQUE_DENIALS: usize = 10_000; + +/// One decoded ETW event, retaining the header context the extractors need. +#[cfg(test)] +struct CollectedEvent { + pid: u32, + filetime: u64, + parts: DecodedEventParts, +} + +#[derive(Clone, Copy)] +enum CollectionMode { + Analyze, + Raw, +} + +type RawEventVisitor<'a> = dyn FnMut(&DecodedEventParts) -> std::io::Result<()> + 'a; + +/// Accumulates bounded analysis results or streams raw diagnostic events +/// during a `ProcessTrace` pass. +struct Accumulator<'visitor> { + mode: CollectionMode, + denials: Vec, + seen: HashSet<(String, learning_mode_core::AccessType)>, + truncated: bool, + raw_visitor: Option<&'visitor mut RawEventVisitor<'visitor>>, + raw_event_count: usize, + decode_error: Option, + visitor_panic: Option>, +} + +impl<'visitor> Accumulator<'visitor> { + fn analyze() -> Self { + Self { + mode: CollectionMode::Analyze, + denials: Vec::new(), + seen: HashSet::new(), + truncated: false, + raw_visitor: None, + raw_event_count: 0, + decode_error: None, + visitor_panic: None, + } + } + + fn raw(visitor: &'visitor mut RawEventVisitor<'visitor>) -> Self { + Self { + mode: CollectionMode::Raw, + denials: Vec::new(), + seen: HashSet::new(), + truncated: false, + raw_visitor: Some(visitor), + raw_event_count: 0, + decode_error: None, + visitor_panic: None, + } + } + + fn add_raw_denial(&mut self, raw: RawDenial) { + let path = if raw.resource_type == learning_mode_core::ResourceType::File { + match path_norm::to_user_visible(&raw.object_name) { + Some(path) if path_norm::is_user_visible_absolute(&path) => path, + Some(_) => return, + None if path_norm::is_user_visible_absolute(&raw.object_name) => { + raw.object_name.clone() + } + None => return, + } + } else { + path_norm::to_user_visible(&raw.object_name).unwrap_or_else(|| raw.object_name.clone()) + }; + let dedup_path = match raw.resource_type { + learning_mode_core::ResourceType::File | learning_mode_core::ResourceType::Other => { + path.to_ascii_lowercase() + } + _ => path.clone(), + }; + if self.seen.contains(&(dedup_path.clone(), raw.access_type)) { + return; + } + if self.denials.len() >= MAX_UNIQUE_DENIALS { + self.truncated = true; + return; + } + self.seen.insert((dedup_path, raw.access_type)); + self.denials.push(DeniedResource { + path, + resource_type: raw.resource_type, + access_type: raw.access_type, + pid: raw.pid, + filetime: raw.filetime, + }); + } + + fn visit_raw_event(&mut self, parts: &DecodedEventParts) { + let Some(visitor) = self.raw_visitor.as_mut() else { + return; + }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| visitor(parts))) { + Ok(Ok(())) => self.raw_event_count += 1, + Ok(Err(error)) => { + self.decode_error = Some(format!("raw event consumer failed: {error}")); + } + Err(payload) => self.visitor_panic = Some(payload), + } + } + + fn into_analysis(self) -> Result { + if let Some(error) = self.decode_error { + return Err(AnalyzeError::Decode(error)); + } + Ok(AnalysisResult { + denials: self.denials, + denied_resources_truncated: self.truncated, + }) + } +} + +/// A [`DenialAnalyzer`] over a sealed learning-mode `.etl` file. +#[derive(Debug, Default, Clone, Copy)] +pub struct EtlDenialAnalyzer; + +impl DenialAnalyzer for EtlDenialAnalyzer { + fn analyze(&self, source_path: &Path) -> Result { + let mut accumulator = Accumulator::analyze(); + process_trace_file(source_path, &mut accumulator)?; + accumulator.into_analysis() + } +} + +/// Runs the pure decode composition over already-collected events: +/// route each event through [`extract_denial`], then normalise + +/// de-duplicate into public [`DeniedResource`]s. Split out from +/// [`EtlDenialAnalyzer::analyze`] so it can be tested with hand-built events +/// that mirror real traces, without a live ETW/TDH read (which needs the +/// provider manifests registered on the machine). +#[cfg(test)] +fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { + dedup_to_resources( + events + .iter() + .filter_map(|e| extract_denial(&e.parts, e.pid, e.filetime)), + ) +} + +/// Streams every decoded event in the ETL to `visitor` for schema discovery +/// and diagnostics, preserving on-disk order without retaining the trace in +/// memory. Returns the number of events delivered. +/// +/// # Errors +/// +/// Returns [`AnalyzeError`] if the trace cannot be opened or processed. +pub fn visit_raw_events( + source_path: &Path, + visitor: &mut RawEventVisitor<'_>, +) -> Result { + let mut accumulator = Accumulator::raw(visitor); + process_trace_file(source_path, &mut accumulator)?; + if let Some(error) = accumulator.decode_error { + return Err(AnalyzeError::Decode(error)); + } + Ok(accumulator.raw_event_count) +} + +/// De-duplicates raw denials by `(user-visible path, accessType)`, +/// normalising kernel paths to drive-letter form and preserving first-seen +/// order. +#[cfg(test)] +fn dedup_to_resources>(raws: I) -> AnalysisResult { + let mut accumulator = Accumulator::analyze(); + for raw in raws { + accumulator.add_raw_denial(raw); + } + accumulator + .into_analysis() + .expect("pure denial accumulation cannot decode-fail") +} + +/// Opens `source_path` as an ETL log file, runs `ProcessTrace` to +/// completion, and returns the decoded events. +fn process_trace_file( + source_path: &Path, + accumulator: &mut Accumulator<'_>, +) -> Result<(), AnalyzeError> { + // Fail fast with a clear error if the file is missing/unreadable, + // rather than surfacing an opaque OpenTraceW Win32 code. + std::fs::File::open(source_path).map_err(|source| AnalyzeError::Open { + path: source_path.display().to_string(), + source, + })?; + + let mut name_wide: Vec = source_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut logfile: EVENT_TRACE_LOGFILEW = unsafe { core::mem::zeroed() }; + logfile.LogFileName = PWSTR(name_wide.as_mut_ptr()); + logfile.Anonymous1.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD; + logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); + logfile.Context = std::ptr::from_mut(accumulator).cast(); + + // SAFETY: `logfile` and `name_wide` outlive the OpenTraceW call; the + // callback pointer is valid and the Context points at a live stack + // value that outlives the ProcessTrace call below. + let handle = unsafe { OpenTraceW(&mut logfile) }; + if handle.Value == INVALID_PROCESSTRACE_HANDLE { + let code = std::io::Error::last_os_error().raw_os_error().unwrap_or(-1) as u32; + return Err(AnalyzeError::Decode(format!( + "OpenTraceW failed for '{}': Win32 error {code}", + source_path.display() + ))); + } + + let handles = [handle]; + // SAFETY: `handles` is valid for the call. In file mode ProcessTrace + // processes all buffered events (invoking our callback synchronously + // on this thread) and returns at end-of-file. + let status = unsafe { ProcessTrace(&handles, None, None) }; + + // SAFETY: closing the consumer handle we opened above. Idempotent. + unsafe { + let _ = CloseTrace(handle); + } + + if let Some(payload) = accumulator.visitor_panic.take() { + std::panic::resume_unwind(payload); + } + + // ERROR_SUCCESS (0) and ERROR_CANCELLED (1223) are both acceptable + // terminal states for a completed file trace. + if status.0 != 0 && status.0 != 1223 { + return Err(AnalyzeError::Decode(format!( + "ProcessTrace failed for '{}': Win32 error {}", + source_path.display(), + status.0 + ))); + } + + Ok(()) +} + +/// ETW record callback, invoked by `ProcessTrace` for every event in the +/// file. Decodes the event via TDH and appends it to the [`Accumulator`] +/// pointed to by `EVENT_RECORD.UserContext`. +/// +/// # Safety +/// Invoked by ETW with a valid `EVENT_RECORD` whose `UserContext` is the +/// `Accumulator` pointer we set on `EVENT_TRACE_LOGFILEW.Context`. +unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { + if event_record.is_null() { + return; + } + // SAFETY: ETW guarantees a valid record; we only read POD header fields. + let header = unsafe { (*event_record).EventHeader }; + let context = unsafe { (*event_record).UserContext } as *mut Accumulator<'_>; + if context.is_null() { + return; + } + + // SAFETY: `context` is the live Accumulator we passed via Context, and + // ProcessTrace invokes this callback synchronously on our thread, so no + // aliasing/concurrency with the owner occurs. + let acc = unsafe { &mut *context }; + if acc.decode_error.is_some() || acc.visitor_panic.is_some() { + return; + } + + let provider = header.ProviderId; + let event_id = header.EventDescriptor.Id; + if matches!(acc.mode, CollectionMode::Analyze) && !is_learning_mode_event(provider, event_id) { + return; + } + + match unsafe { tdh_decode::decode_event_parts(event_record) } { + Ok(parts) => match acc.mode { + CollectionMode::Analyze => { + if let Some(raw) = extract_denial(&parts, header.ProcessId, header.TimeStamp as u64) + { + acc.add_raw_denial(raw); + } + } + CollectionMode::Raw => acc.visit_raw_event(&parts), + }, + Err(error) => { + if acc.decode_error.is_none() { + acc.decode_error = + Some(format!("provider {:?} event {event_id}: {error}", provider)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_core::{AccessType, ResourceType}; + + #[test] + fn raw_visitor_panic_is_captured_inside_callback_state() { + let mut visitor = + |_: &DecodedEventParts| -> std::io::Result<()> { panic!("simulated visitor panic") }; + let mut accumulator = Accumulator::raw(&mut visitor); + let parts = DecodedEventParts { + provider: windows::core::GUID::from_u128(0), + event_id: 1, + props: Vec::new(), + }; + + accumulator.visit_raw_event(&parts); + + assert!(accumulator.visitor_panic.is_some()); + } + + fn raw(path: &str, access: AccessType, rt: ResourceType) -> RawDenial { + RawDenial { + pid: 1, + resource_type: rt, + object_name: path.to_string(), + access_type: access, + filetime: 1, + event_id: 4907, + } + } + + #[test] + fn dedup_collapses_repeated_path_access_pairs() { + let denials = vec![ + raw(r"C:\a", AccessType::Read, ResourceType::File), + raw(r"C:\a", AccessType::Read, ResourceType::File), + raw(r"C:\a", AccessType::Write, ResourceType::File), + raw(r"C:\b", AccessType::Read, ResourceType::File), + ]; + let out = dedup_to_resources(denials).denials; + assert_eq!(out.len(), 3, "unique (path, access) pairs"); + assert_eq!(out[0].path, r"C:\a"); + assert_eq!(out[0].access_type, AccessType::Read); + assert_eq!(out[1].access_type, AccessType::Write); + assert_eq!(out[2].path, r"C:\b"); + } + + #[test] + fn dedup_preserves_first_seen_order() { + let denials = vec![ + raw(r"C:\z", AccessType::Read, ResourceType::File), + raw(r"C:\a", AccessType::Read, ResourceType::File), + ]; + let out = dedup_to_resources(denials).denials; + assert_eq!(out[0].path, r"C:\z"); + assert_eq!(out[1].path, r"C:\a"); + } + + #[test] + fn file_denials_require_a_canonical_user_visible_path() { + let denials = vec![ + raw( + r"\Device\Mup\server\share\file.txt", + AccessType::Read, + ResourceType::File, + ), + raw( + r"\Device\UnknownVolume\file.txt", + AccessType::Read, + ResourceType::File, + ), + raw(r"\??\C:relative.txt", AccessType::Read, ResourceType::File), + raw(r"\??\PIPE\name", AccessType::Read, ResourceType::File), + raw( + r"\Device\Mup\server\pipe\name", + AccessType::Read, + ResourceType::File, + ), + ]; + + let out = dedup_to_resources(denials).denials; + + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, r"\\server\share\file.txt"); + } + + #[test] + fn dedup_is_case_insensitive_and_preserves_first_spelling() { + let denials = vec![ + raw(r"C:\Data\File.txt", AccessType::Read, ResourceType::File), + raw(r"c:\data\file.TXT", AccessType::Read, ResourceType::File), + ]; + let out = dedup_to_resources(denials).denials; + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, r"C:\Data\File.txt"); + } + + #[test] + fn result_is_bounded_and_reports_truncation() { + let denials = (0..=MAX_UNIQUE_DENIALS).map(|index| { + raw( + &format!(r"C:\data\{index}.txt"), + AccessType::Read, + ResourceType::File, + ) + }); + let out = dedup_to_resources(denials); + assert_eq!(out.denials.len(), MAX_UNIQUE_DENIALS); + assert!(out.denied_resources_truncated); + } + + #[test] + fn analyze_missing_file_returns_open_error() { + let analyzer = EtlDenialAnalyzer; + let err = analyzer + .analyze(Path::new(r"C:\does\not\exist\nope.etl")) + .unwrap_err(); + assert!(matches!(err, AnalyzeError::Open { .. }), "got {err:?}"); + } + + #[test] + fn target_event_decode_failure_is_reported() { + let accumulator = Accumulator { + decode_error: Some("event 14 malformed".to_string()), + ..Accumulator::analyze() + }; + let error = accumulator.into_analysis().expect_err("decode must fail"); + assert!(matches!(error, AnalyzeError::Decode(_))); + assert!(error.to_string().contains("event 14 malformed")); + } + + // ---- decode composition over real event shapes ------------------------ + // + // These exercise the full `analyze` pipeline minus the OS trace read: + // `extract_denial` routing -> path normalisation -> dedup. The events + // mirror captures taken on hardware for both learning modes (see the + // module/extractor docs); a live ETW/TDH read isn't used because it + // needs the provider manifests registered on the machine. + + fn event(event_id: u16, pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { + CollectedEvent { + pid, + filetime, + parts: DecodedEventParts { + provider: if event_id == 4907 { + crate::extractors::PRIVACY_LEARNING_MODE_PROVIDER + } else { + crate::extractors::KERNEL_GENERAL_PROVIDER + }, + event_id, + props: kv + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + }, + } + } + + /// Mirrors the real `Mode="Normal"` (`block`) capture: file/registry + /// access checks as event 14 plus a compact capability denial as event 28. + #[test] + fn block_shape_decodes_and_classifies() { + let events = vec![ + // File write (DELETE | FILE_READ_DATA -> Write), \??\ prefix. + event( + 14, + 5480, + 10, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"File\""), + ("ObjectName", "\"\\??\\C:\\data\\test\\bin\\\""), + ("AccessMask", "0x10001"), + ], + ), + // Registry read (KEY_READ 0x20019 -> Read) stays kernel-form. + event( + 14, + 6860, + 11, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"Key\""), + ("ObjectName", "\"\\REGISTRY\\USER\\.DEFAULT\\Console\""), + ("AccessMask", "0x20019"), + ], + ), + // Capability denial (event 28) with a decoded identifier. + event( + 28, + 0, + 12, + &[ + ("ProcessName", "\"conhost.exe\""), + ("ProcessId", "0x1acc"), + ("Denied", "true"), + ("PackageSid", "S-1-15-3-1"), + ], + ), + ]; + + let out = resources_from_events(&events).denials; + assert_eq!(out.len(), 3); + + assert_eq!(out[0].path, r"C:\data\test\bin\"); + assert_eq!(out[0].resource_type, ResourceType::File); + assert_eq!(out[0].access_type, AccessType::Write); + assert_eq!(out[0].pid, 5480); + + assert_eq!(out[1].path, r"\REGISTRY\USER\.DEFAULT\Console"); + assert_eq!(out[1].resource_type, ResourceType::Other); + assert_eq!(out[1].access_type, AccessType::Read); + + assert_eq!(out[2].path, "S-1-15-3-1"); + assert_eq!(out[2].resource_type, ResourceType::Capability); + assert_eq!(out[2].access_type, AccessType::Unknown); + assert_eq!(out[2].pid, 0x1acc, "pid from payload ProcessId"); + } + + /// Mirrors the real `Mode="Permissive"` (`allow`) capture: the same + /// file/registry checks plus a capability check folded into an + /// empty-`ObjectType` event 14 (there is no event 28 in this mode). + #[test] + fn allow_shape_omits_unidentified_capability_event() { + let events = vec![ + event( + 14, + 2292, + 20, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"File\""), + ("ObjectName", "\"\\??\\C:\\data\\test\\bin\\\""), + ("AccessMask", "0x10001"), + ], + ), + // Empty ObjectType == brokered-capability check. + event( + 14, + 5900, + 21, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"\""), + ("ObjectName", "\"\""), + ("AccessMask", "0x1"), + ], + ), + ]; + + let out = resources_from_events(&events).denials; + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, r"C:\data\test\bin\"); + assert_eq!(out[0].access_type, AccessType::Write); + } + + #[test] + fn unidentified_capability_events_are_omitted() { + let events = vec![ + event( + 14, + 1, + 1, + &[ + ("ObjectType", "\"\""), + ("ObjectName", "\"\""), + ("AccessMask", "0x1"), + ], + ), + event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "true")]), + event(28, 0, 3, &[("ProcessId", "0x20"), ("Denied", "true")]), + ]; + assert!(resources_from_events(&events).denials.is_empty()); + } + + /// Non-actionable object types and not-denied capability records are + /// dropped by the pipeline; unknown event IDs are ignored. + #[test] + fn non_actionable_events_are_dropped() { + let events = vec![ + event(14, 1, 1, &[("ObjectType", "\"Section\"")]), + event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "false")]), + event(9999, 1, 3, &[("Foo", "\"bar\"")]), + ]; + assert!(resources_from_events(&events).denials.is_empty()); + } +} diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs new file mode 100644 index 00000000..e28ad537 --- /dev/null +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -0,0 +1,704 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! ETW event -> [`RawDenial`] extractors. +//! +//! These operate on the generic [`DecodedEventParts`] shape produced by +//! [`crate::tdh_decode`], so they can be unit-tested with hand-built +//! fixtures without a live/sealed ETW trace. A [`RawDenial`] still carries +//! the *kernel-form* object path; [`crate::etl_decode`] path-normalises and +//! de-duplicates them into the public +//! [`learning_mode_core::DeniedResource`]. +//! +//! These policy-oriented extractors intentionally do not reuse the diagnostic +//! console's display mapping. The console accepts broad real-time provider +//! traffic and formats it for humans; this module accepts only the +//! learning-mode providers and produces the stable cross-platform denial +//! model used for policy generation. +//! +//! ## Event vocabulary +//! +//! The learning-mode ETL carries a set of event IDs that map onto the +//! resource types we surface. This list grows as more denial sources are +//! decoded; unknown event IDs are discarded. The IDs handled today: +//! +//! - **14 / 4907 — access check** — the primary denial event +//! (`ObjectType` / `ObjectName` / `AccessMask`). `ObjectType` selects the +//! resource type: `File` → [`ResourceType::File`], `Key` → +//! [`ResourceType::Other`] (registry), and an **empty** `ObjectType` is a +//! brokered-capability check → [`ResourceType::Capability`]. Any other +//! object type (Section, Process, Thread, ...) is dropped (not actionable +//! via sandbox policy). The [`AccessType`] is derived from the +//! `AccessMask` field (see [`access_type_from_mask`]). Emitted under both +//! learning modes (`block` → `Mode="Normal"`, `allow` → +//! `Mode="Permissive"`). +//! - **27 — `LearningModeViolation`** — UI-surface denials → +//! [`ResourceType::Ui`]. Carries no usable access mask, so the access type +//! stays [`AccessType::Unknown`]. +//! - **28 — capability denial** — a compact capability-access-manager +//! record (`Denied` / `PackageSid` / `ProcessId`), emitted under +//! `block`; `allow` folds the same information into the +//! empty-`ObjectType` event 14 above. Mapped to [`ResourceType::Capability`]. +//! Records are emitted only when TDH surfaces a decoded capability +//! identifier; otherwise they are omitted until SID/ACE decoding is +//! available. + +use learning_mode_core::{AccessType, ResourceType}; +use windows::core::GUID; + +/// Microsoft-Windows-Kernel-General provider. +pub(crate) const KERNEL_GENERAL_PROVIDER: GUID = GUID { + data1: 0xa68c_a8b7, + data2: 0x004f, + data3: 0xd7b6, + data4: [0xa6, 0x98, 0x07, 0xe2, 0xde, 0x0f, 0x1f, 0x5d], +}; + +/// Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode provider. +pub(crate) const PRIVACY_LEARNING_MODE_PROVIDER: GUID = GUID { + data1: 0x811a_1ddb, + data2: 0x2e69, + data3: 0x5f25, + data4: [0xad, 0xc0, 0x4b, 0x18, 0x61, 0x70, 0xe7, 0x60], +}; + +/// Pre-decoded event payload handed to the extractors. +/// +/// The trace consumer decodes each raw `EVENT_RECORD` into this shape via +/// [`crate::tdh_decode::decode_event_parts`] before routing it here. The +/// extractors take only this representation so they stay unit-testable. +#[derive(Debug, Clone)] +pub struct DecodedEventParts { + /// Provider that emitted the event. Event IDs are provider-scoped. + pub provider: GUID, + /// Originating ETW event ID. + pub event_id: u16, + /// `(name, value)` pairs from the decoded payload. String values are + /// often TDH-quoted; extractors trim the surrounding quotes. + pub props: Vec<(String, String)>, +} + +/// A denial extracted from one ETW event, before path normalisation and +/// de-duplication. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawDenial { + /// Process ID that triggered the denial. + pub pid: u32, + /// Classified resource type. + pub resource_type: ResourceType, + /// Object name in kernel form (e.g. `\Device\HarddiskVolumeN\...`) or, + /// for non-file resources, the raw identifier the event carried. Empty + /// when the event carries no resolvable name (e.g. a capability denial + /// whose name is still encoded in an undecoded SID blob). + pub object_name: String, + /// Access the workload was attempting. + pub access_type: AccessType, + /// Kernel `FILETIME` of the event. + pub filetime: u64, + /// Originating ETW event ID (kept for diagnostics). + pub event_id: u16, +} + +/// Routes a decoded event to the matching extractor by its event ID. +/// +/// Returns `None` for events that are not learning-mode denials or that +/// carry an object type we don't surface. +pub fn extract_denial(parts: &DecodedEventParts, pid: u32, filetime: u64) -> Option { + if !is_learning_mode_event(parts.provider, parts.event_id) { + return None; + } + match parts.event_id { + 14 | 4907 => build_denial_from_access_check(parts, pid, filetime), + 27 => build_denial_from_learning_mode(parts, pid, filetime), + 28 => build_denial_from_capability(parts, pid, filetime), + _ => None, + } +} + +/// Whether a provider/event pair belongs to the Learning Mode capture schema. +pub(crate) fn is_learning_mode_event(provider: GUID, event_id: u16) -> bool { + if provider == KERNEL_GENERAL_PROVIDER { + matches!(event_id, 14 | 27 | 28) + } else if provider == PRIVACY_LEARNING_MODE_PROVIDER { + matches!(event_id, 14 | 27 | 4907) + } else { + false + } +} + +/// Builds a [`RawDenial`] from an access-check (event 14 / 4907) payload. +/// +/// The `ObjectType` field selects the resource type: `File` and `Key` +/// (registry) map to concrete resources, an **empty** `ObjectType` is a +/// brokered-capability check, and any other object type (Section, Process, +/// Thread, ...) is dropped as not actionable via sandbox policy. An absent +/// `ObjectType` field drops the event. +/// +/// For file/registry resources the [`AccessType`] is derived from the +/// event's `AccessMask` field (the desired access the caller was denied; +/// see [`access_type_from_mask`]); when the field is absent or unparseable +/// the type falls back to [`AccessType::Unknown`] so a decode gap never +/// drops the denial itself. Capability checks carry a mask that is not a +/// read/write/execute verb, so their access type is left `Unknown`. +pub fn build_denial_from_access_check( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Option { + let object_type = find_prop(&parts.props, "ObjectType")?; + let object_type_str = object_type.trim_matches('"'); + + let resource_type = match object_type_str { + "File" => ResourceType::File, + "Key" => ResourceType::Other, + // A present-but-empty object type is a brokered-capability check. + "" => ResourceType::Capability, + _ => return None, + }; + + let object_name = find_prop(&parts.props, "ObjectName") + .map(|v| v.trim_matches('"').to_string()) + .filter(|name| !name.is_empty())?; + + 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 + // classifier over it. + AccessType::Unknown + } else { + // Registry keys and files share the standard/generic mask bits but + // assign different meanings to the object-specific low bits, so the + // classifier needs to know which vocabulary applies. + let is_registry = object_type_str == "Key"; + find_prop(&parts.props, "AccessMask") + .and_then(|v| parse_u32(v)) + .map(|mask| access_type_from_mask(mask, is_registry)) + .unwrap_or(AccessType::Unknown) + }; + + Some(RawDenial { + pid, + resource_type, + object_name, + access_type, + filetime, + event_id: parts.event_id, + }) +} + +/// Builds a [`RawDenial`] from a `LearningModeViolation` (event 27) payload. +/// +/// These represent UI-surface denials. `Category` identifies the class and +/// `Detail` identifies the concrete UI operation; `ProcessName` is the caller +/// and must not be emitted as the denied resource. +pub fn build_denial_from_learning_mode( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Option { + let category = find_prop(&parts.props, "Category")? + .trim_matches('"') + .to_string(); + let detail = find_prop(&parts.props, "Detail")?.trim_matches('"'); + let object_name = match category.as_str() { + "1" => "ConvertToGui".to_string(), + "2" => parse_u32(detail) + .and_then(ui_operation_name) + .map(str::to_string) + .unwrap_or_else(|| format!("UiOperation({detail})")), + _ => format!("Category({category})/Detail({detail})"), + }; + + Some(RawDenial { + pid, + resource_type: ResourceType::Ui, + object_name, + access_type: AccessType::Unknown, + filetime, + event_id: parts.event_id, + }) +} + +/// Builds a [`RawDenial`] from a capability-denial (event 28) payload. +/// +/// Emitted under `block`. The record reports a `Denied` boolean; we +/// only surface actual denials. The originating process is taken from the +/// payload `ProcessId` (which is more precise than the ETW header pid for +/// brokered checks) when present, else the header pid. Records without a +/// decoded capability identifier are omitted. +pub fn build_denial_from_capability( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Option { + // A partially decoded event must not become a policy recommendation. + if !find_prop(&parts.props, "Denied")? + .trim_matches('"') + .eq_ignore_ascii_case("true") + { + return None; + } + + let pid = find_prop(&parts.props, "ProcessId") + .and_then(|v| parse_u32(v)) + .unwrap_or(pid); + + // Emit only when the decoder has surfaced a usable identifier. Empty + // capability records collapse during dedup and cannot guide policy fixes. + let object_name = find_prop(&parts.props, "CapabilityName") + .or_else(|| find_prop(&parts.props, "Capability")) + .or_else(|| find_prop(&parts.props, "PackageSid")) + .map(|v| v.trim_matches('"').to_string()) + .filter(|name| !name.is_empty() && name != "" && name != "")?; + + Some(RawDenial { + pid, + resource_type: ResourceType::Capability, + object_name, + access_type: AccessType::Unknown, + filetime, + event_id: parts.event_id, + }) +} + +fn ui_operation_name(value: u32) -> Option<&'static str> { + match value { + 0x001 => Some("Handles"), + 0x002 => Some("ReadClipboard"), + 0x004 => Some("WriteClipboard"), + 0x008 => Some("SystemParameters"), + 0x010 => Some("DisplaySettings"), + 0x020 => Some("GlobalAtoms"), + 0x040 => Some("Desktop"), + 0x080 => Some("ExitWindows"), + 0x100 => Some("IME"), + 0x200 => Some("Injection"), + _ => None, + } +} + +/// Parses a `"0x…"` / decimal / bare-hex property value into a `u32`. +/// +/// The `AccessMask` and `ProcessId` templates render as `win:HexInt32`, so +/// the TDH decoder emits a `"0x…"` string (e.g. `"0x120089"`, `"0x1acc"`). +/// We accept a leading `0x`/`0X` (hex) and, defensively, a bare decimal or +/// bare-hex form in case a future decoder path formats it differently. +/// Returns `None` when the value can't be parsed as a 32-bit integer. +fn parse_u32(raw: &str) -> Option { + let s = raw.trim().trim_matches('"').trim(); + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + return u32::from_str_radix(hex, 16).ok(); + } + // No explicit prefix: prefer decimal, fall back to hex. + s.parse::() + .ok() + .or_else(|| u32::from_str_radix(s, 16).ok()) +} + +/// Classifies a Windows access mask into a single [`AccessType`]. +/// +/// A mask often requests several rights at once (e.g. `FILE_GENERIC_READ` +/// bundles multiple read bits). Since `AccessType` is single-valued, we +/// return the highest-privilege intent present, in the order +/// **Write → Execute → Read**, so an approval that grants the reported type +/// also covers everything else the caller asked for. Mutating rights that +/// have no dedicated variant (delete, create, take-ownership, change DACL) +/// fold into `Write`. A mask with no recognised right (e.g. only +/// `SYNCHRONIZE` or `MAXIMUM_ALLOWED`) yields [`AccessType::Unknown`]. +/// +/// `is_registry` selects the object-specific low-bit vocabulary: files and +/// registry keys share the standard/generic bits but disagree on bits like +/// `0x10` (`FILE_WRITE_EA` vs `KEY_NOTIFY`) and `0x20` (`FILE_EXECUTE` vs +/// `KEY_CREATE_LINK`). +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). + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + const GENERIC_EXECUTE: u32 = 0x2000_0000; + const GENERIC_ALL: u32 = 0x1000_0000; + + let standard_write = DELETE | WRITE_DAC | WRITE_OWNER; + + let (read_bits, write_bits, execute_bits) = if is_registry { + // Registry key-specific rights (winnt.h KEY_*). + const KEY_QUERY_VALUE: u32 = 0x0001; + const KEY_SET_VALUE: u32 = 0x0002; + const KEY_CREATE_SUB_KEY: u32 = 0x0004; + const KEY_ENUMERATE_SUB_KEYS: u32 = 0x0008; + 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_SET_VALUE + | KEY_CREATE_SUB_KEY + | KEY_CREATE_LINK + | standard_write + | GENERIC_WRITE + | GENERIC_ALL, + // Registry has no execute concept (KEY_EXECUTE aliases KEY_READ). + 0, + ) + } else { + // File/directory-specific rights (winnt.h FILE_*). + const FILE_READ_DATA: u32 = 0x0001; // a.k.a. FILE_LIST_DIRECTORY + const FILE_WRITE_DATA: u32 = 0x0002; // a.k.a. FILE_ADD_FILE + const FILE_APPEND_DATA: u32 = 0x0004; // a.k.a. FILE_ADD_SUBDIRECTORY + const FILE_READ_EA: u32 = 0x0008; + const FILE_WRITE_EA: u32 = 0x0010; + const FILE_EXECUTE: u32 = 0x0020; // a.k.a. FILE_TRAVERSE + const FILE_DELETE_CHILD: u32 = 0x0040; + 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_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_DELETE_CHILD + | FILE_WRITE_ATTRIBUTES + | standard_write + | GENERIC_WRITE + | GENERIC_ALL, + FILE_EXECUTE | GENERIC_EXECUTE, + ) + }; + + if mask & write_bits != 0 { + AccessType::Write + } else if mask & execute_bits != 0 { + AccessType::Execute + } else if mask & read_bits != 0 { + AccessType::Read + } else { + AccessType::Unknown + } +} + +fn find_prop<'a>(props: &'a [(String, String)], name: &str) -> Option<&'a String> { + props.iter().find(|(k, _)| k == name).map(|(_, v)| v) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXED_FILETIME: u64 = 132_847_890_123_456_789; + + fn parts(event_id: u16, kv: &[(&str, &str)]) -> DecodedEventParts { + let provider = if event_id == 4907 { + PRIVACY_LEARNING_MODE_PROVIDER + } else { + KERNEL_GENERAL_PROVIDER + }; + parts_with_provider(provider, event_id, kv) + } + + fn parts_with_provider( + provider: GUID, + event_id: u16, + kv: &[(&str, &str)], + ) -> DecodedEventParts { + DecodedEventParts { + provider, + event_id, + props: kv + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + } + } + + // ---- event 14 / 4907 access check ------------------------------------- + + #[test] + fn access_check_file_denial_read_mask() { + let p = parts( + 14, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"File\""), + ( + "ObjectName", + "\"\\Device\\HarddiskVolume3\\Users\\x\\f.txt\"", + ), + // FILE_GENERIC_READ (0x120089). + ("AccessMask", "0x120089"), + ], + ); + let ev = extract_denial(&p, 7777, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.event_id, 14); + assert_eq!(ev.pid, 7777); + assert_eq!(ev.resource_type, ResourceType::File); + assert_eq!(ev.object_name, r"\Device\HarddiskVolume3\Users\x\f.txt"); + assert_eq!(ev.access_type, AccessType::Read); + assert_eq!(ev.filetime, FIXED_FILETIME); + } + + #[test] + fn access_check_event_4907_still_routed() { + let p = parts( + 4907, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"c:\\x\""), + ("AccessMask", "0x1"), + ], + ); + let ev = extract_denial(&p, 1, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.resource_type, ResourceType::File); + assert_eq!(ev.access_type, AccessType::Read); + } + + #[test] + fn access_check_write_mask_classified_write() { + let p = parts( + 14, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", "\"c:\\x\""), + // DELETE | FILE_READ_DATA (0x10001) — write wins. + ("AccessMask", "0x10001"), + ], + ); + let ev = extract_denial(&p, 1, FIXED_FILETIME).unwrap(); + assert_eq!(ev.access_type, AccessType::Write); + } + + #[test] + fn access_check_key_denial_uses_registry_vocabulary() { + let p = parts( + 14, + &[ + ("ObjectType", "\"Key\""), + ("ObjectName", "\"\\REGISTRY\\USER\\.DEFAULT\\Console\""), + // KEY_READ (0x20019): READ_CONTROL | KEY_QUERY_VALUE | + // KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY -> Read. + ("AccessMask", "0x20019"), + ], + ); + let ev = extract_denial(&p, 1, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.resource_type, ResourceType::Other); + assert_eq!(ev.access_type, AccessType::Read); + } + + #[test] + fn access_check_empty_capability_identifier_is_dropped() { + // Permissive-mode capability check: present-but-empty ObjectType, + // empty ObjectName, mask 0x1 (not a file read verb here). + let p = parts( + 14, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"\""), + ("ObjectName", "\"\""), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 5900, FIXED_FILETIME).is_none()); + } + + #[test] + fn access_check_empty_file_and_registry_identifiers_are_dropped() { + for object_type in ["File", "Key"] { + let p = parts( + 14, + &[ + ("ObjectType", object_type), + ("ObjectName", "\"\""), + ("AccessMask", "0x1"), + ], + ); + assert!(extract_denial(&p, 5900, FIXED_FILETIME).is_none()); + } + } + + #[test] + fn access_check_missing_mask_defaults_to_unknown() { + let p = parts( + 4907, + &[("ObjectType", "\"File\""), ("ObjectName", "\"c:\\x\"")], + ); + let ev = extract_denial(&p, 1, FIXED_FILETIME).unwrap(); + assert_eq!(ev.access_type, AccessType::Unknown); + } + + #[test] + fn access_check_unknown_object_type_dropped() { + let p = parts(14, &[("ObjectType", "\"Section\"")]); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + #[test] + fn access_check_absent_object_type_dropped() { + let p = parts(14, &[("ObjectName", "\"x\"")]); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + // ---- event 27 UI ------------------------------------------------------ + + #[test] + fn learning_mode_violation_extracted_as_ui() { + let p = parts( + 27, + &[ + ("ProcessName", "\"caller.exe\""), + ("Category", "2"), + ("Detail", "4"), + ], + ); + let ev = extract_denial(&p, 9999, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.event_id, 27); + assert_eq!(ev.resource_type, ResourceType::Ui); + assert_eq!(ev.access_type, AccessType::Unknown); + assert_eq!(ev.object_name, "WriteClipboard"); + } + + // ---- event 28 capability denial --------------------------------------- + + #[test] + fn capability_denial_event_28_extracted() { + // Real block shape: image name + hex ProcessId + Denied. + let p = parts( + 28, + &[ + ("ProcessName", "\"conhost.exe\""), + ("ProcessId", "0x1acc"), + ("Category", "1"), + ("Denied", "true"), + ("PackageSid", "S-1-15-3-1"), + ], + ); + let ev = extract_denial(&p, 42, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.resource_type, ResourceType::Capability); + assert_eq!(ev.access_type, AccessType::Unknown); + // pid comes from the payload ProcessId (0x1acc), not the header. + assert_eq!(ev.pid, 0x1acc); + assert_eq!(ev.object_name, "S-1-15-3-1"); + } + + #[test] + fn capability_denial_not_denied_is_dropped() { + let p = parts(28, &[("ProcessId", "0x10"), ("Denied", "false")]); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + #[test] + fn capability_denial_without_denied_field_is_dropped() { + let p = parts(28, &[("PackageSid", "S-1-15-3-1")]); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + #[test] + fn unrelated_provider_event_id_is_dropped() { + let p = parts_with_provider( + GUID::from_u128(0x12345678_1234_1234_1234_1234567890ab), + 27, + &[("Category", "2"), ("Detail", "4")], + ); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + #[test] + fn capability_denial_falls_back_to_header_pid() { + let p = parts(28, &[("Denied", "true"), ("PackageSid", "S-1-15-3-1")]); + let ev = extract_denial(&p, 555, FIXED_FILETIME).unwrap(); + assert_eq!(ev.pid, 555); + } + + #[test] + fn unrelated_event_ignored() { + let p = parts(9999, &[("Foo", "\"bar\"")]); + assert!(extract_denial(&p, 1, FIXED_FILETIME).is_none()); + } + + // ---- parse_u32 -------------------------------------------------------- + + #[test] + fn parse_u32_reads_hex_prefixed() { + assert_eq!(parse_u32("0x120089"), Some(0x0012_0089)); + assert_eq!(parse_u32("0X1"), Some(1)); + assert_eq!(parse_u32("0x1acc"), Some(0x1acc)); + // TDH could conceivably wrap or pad the value; be tolerant. + assert_eq!(parse_u32(" \"0x2\" "), Some(2)); + } + + #[test] + fn parse_u32_reads_bare_forms_and_rejects_garbage() { + assert_eq!(parse_u32("32"), Some(32)); + // Bare hex fallback (no 0x prefix, not valid decimal). + assert_eq!(parse_u32("ff"), Some(0xff)); + assert_eq!(parse_u32(""), None); + assert_eq!(parse_u32(""), None); + // Larger than u32. + assert_eq!(parse_u32("0x100000000"), None); + } + + // ---- access_type_from_mask (files) ------------------------------------ + + #[test] + fn file_mask_read_write_execute_priority() { + // Pure reads. + assert_eq!(access_type_from_mask(0x0001, false), AccessType::Read); // FILE_READ_DATA + assert_eq!(access_type_from_mask(0x0012_0089, false), AccessType::Read); // FILE_GENERIC_READ + // Pure execute / traverse. + assert_eq!(access_type_from_mask(0x0020, false), AccessType::Execute); // FILE_EXECUTE + assert_eq!( + access_type_from_mask(0x2000_0000, false), + AccessType::Execute + ); // GENERIC_EXECUTE + // Writes (incl. delete / take-ownership fold into Write). + assert_eq!(access_type_from_mask(0x0002, false), AccessType::Write); // FILE_WRITE_DATA + assert_eq!(access_type_from_mask(0x0040, false), AccessType::Write); // FILE_DELETE_CHILD + assert_eq!(access_type_from_mask(0x0001_0000, false), AccessType::Write); // DELETE + assert_eq!(access_type_from_mask(0x4000_0000, false), AccessType::Write); // GENERIC_WRITE + assert_eq!(access_type_from_mask(0x1000_0000, false), AccessType::Write); // GENERIC_ALL + // Priority: write beats execute beats read when several are set. + assert_eq!( + access_type_from_mask(0x0001 | 0x0020 | 0x0002, false), + AccessType::Write + ); + assert_eq!( + access_type_from_mask(0x0001 | 0x0020, false), + AccessType::Execute + ); + } + + #[test] + fn file_mask_no_recognised_right_is_unknown() { + // SYNCHRONIZE (0x100000) alone and MAXIMUM_ALLOWED (0x02000000) alone. + assert_eq!( + access_type_from_mask(0x0010_0000, false), + AccessType::Unknown + ); + assert_eq!( + access_type_from_mask(0x0200_0000, false), + AccessType::Unknown + ); + assert_eq!(access_type_from_mask(0, false), AccessType::Unknown); + } + + // ---- access_type_from_mask (registry) --------------------------------- + + #[test] + fn key_mask_uses_registry_vocabulary() { + // KEY_QUERY_VALUE / ENUMERATE / NOTIFY are reads. + assert_eq!(access_type_from_mask(0x0001, true), AccessType::Read); // KEY_QUERY_VALUE + assert_eq!(access_type_from_mask(0x0010, true), AccessType::Read); // KEY_NOTIFY (write for files!) + assert_eq!(access_type_from_mask(0x2000_0000, true), AccessType::Read); // GENERIC_EXECUTE maps to KEY_READ + // KEY_SET_VALUE / CREATE_SUB_KEY / CREATE_LINK are writes. + assert_eq!(access_type_from_mask(0x0002, true), AccessType::Write); // KEY_SET_VALUE + 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); + } +} diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index 8bf6c86d..294caa9e 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -35,6 +35,19 @@ mod lifecycle; #[cfg(target_os = "windows")] mod secenv; +#[cfg(target_os = "windows")] +mod etl_decode; +#[cfg(target_os = "windows")] +mod extractors; +#[cfg(target_os = "windows")] +mod path_norm; +#[cfg(target_os = "windows")] +mod tdh_decode; + +#[cfg(target_os = "windows")] +pub use etl_decode::{visit_raw_events, EtlDenialAnalyzer}; +#[cfg(target_os = "windows")] +pub use extractors::DecodedEventParts; #[cfg(target_os = "windows")] pub use ffi::{is_learning_mode_api_available, LearningModeApi, LearningModeTraceHandle}; #[cfg(target_os = "windows")] diff --git a/src/backends/learning_mode/windows/src/path_norm.rs b/src/backends/learning_mode/windows/src/path_norm.rs new file mode 100644 index 00000000..4eb35caf --- /dev/null +++ b/src/backends/learning_mode/windows/src/path_norm.rs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Kernel-form -> user-visible path normalization. +//! +//! ETW emits filesystem paths in two kernel forms: +//! +//! - DOS-device forms (`\??\`, `\\?\`, or `\\.\`) followed by a drive +//! path or `UNC\server\share`, and +//! - the device form `\Device\HarddiskVolume3\Users\foo\file.txt`. +//! +//! User-visible paths use drive letters (`C:\Users\foo\file.txt`). This +//! module owns both mappings. +//! +//! The DOS-device forms are pure textual prefix strips. For the `\Device\` +//! form we walk `A:` through `Z:` calling `QueryDosDeviceW` to discover each +//! drive's kernel mount (`\Device\HarddiskVolumeN`, `\Device\CdRomN`, etc.), +//! then check the input +//! path for a prefix match. The device map is cached for the lifetime of +//! the process -- the mapping is stable in practice (drive-letter changes +//! during a single workload run are vanishingly rare). +//! +//! MUP redirector paths are converted to UNC paths. Non-file paths such as +//! registry `\REGISTRY\Machine\...` are not recognized. + +use std::sync::OnceLock; + +use windows::core::PCWSTR; +use windows::Win32::Storage::FileSystem::QueryDosDeviceW; + +/// Cached `(drive_letter, kernel_prefix)` table, e.g. +/// `[("C:", "\\Device\\HarddiskVolume3"), ...]`. +static DRIVE_MAP: OnceLock> = OnceLock::new(); + +/// Maps a kernel-form path to its user-visible drive-letter form. +/// +/// Returns `Some(canonical)` when the input starts with the NT +/// DOS-devices prefix (`\??\`, `\\?\`, or `\\.\`) or a +/// known `\Device\HarddiskVolumeN\...` prefix, or the MUP redirector prefix. +/// Returns `None` when the path is not a filesystem path that can be +/// canonicalized (registry or unknown device). +pub fn to_user_visible(kernel_path: &str) -> Option { + // DOS-device prefixes map directly to the path that follows. The UNC + // spelling retains its network-path leading slashes. + if let Some(rest) = kernel_path + .strip_prefix(r"\??\") + .or_else(|| kernel_path.strip_prefix(r"\\?\")) + .or_else(|| kernel_path.strip_prefix(r"\\.\")) + { + if let Some(prefix) = rest + .get(..4) + .filter(|prefix| prefix.eq_ignore_ascii_case(r"UNC\")) + { + let unc = &rest[prefix.len()..]; + return Some(format!(r"\\{unc}")); + } + return Some(rest.to_string()); + } + + if let Some(rest) = kernel_path.strip_prefix(r"\Device\Mup\") { + let rest = if let Some(after_dfs) = rest.strip_prefix(r"DfsClient\") { + if after_dfs.starts_with(';') { + let (_, after_connection) = after_dfs.split_once('\\')?; + after_connection + } else { + after_dfs + } + } else if rest.starts_with(';') { + let (_, after_redirector) = rest.split_once('\\')?; + if after_redirector.starts_with(';') { + let (_, after_connection) = after_redirector.split_once('\\')?; + after_connection + } else { + after_redirector + } + } else { + rest + }; + if !rest.is_empty() { + return Some(format!(r"\\{rest}")); + } + return None; + } + + if !kernel_path.starts_with(r"\Device\") { + return None; + } + + let map = DRIVE_MAP.get_or_init(load_drive_map); + + map_device_path(kernel_path, map) +} + +fn map_device_path(kernel_path: &str, map: &[(String, String)]) -> Option { + for (letter, prefix) in map { + if let Some(rest) = kernel_path.strip_prefix(prefix.as_str()) { + if rest.is_empty() || rest.starts_with('\\') { + return Some(format!("{letter}{rest}")); + } + } + } + None +} + +/// Returns whether `path` is already an absolute user-visible DOS or UNC path. +pub fn is_user_visible_absolute(path: &str) -> bool { + let bytes = path.as_bytes(); + (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/')) + || (path.starts_with(r"\\") + && !path.starts_with(r"\\?\") + && !path.starts_with(r"\\.\") + && !is_unc_named_pipe(path)) +} + +fn is_unc_named_pipe(path: &str) -> bool { + let mut components = path.trim_start_matches('\\').split('\\'); + let _server = components.next(); + components + .next() + .is_some_and(|share| share.eq_ignore_ascii_case("pipe")) +} + +/// Test-only: rebuilds the drive map without consulting the cache. +#[cfg(test)] +pub(crate) fn rebuild_drive_map_for_tests() -> Vec<(String, String)> { + load_drive_map() +} + +fn load_drive_map() -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut buf = [0u16; 260]; + + for c in b'A'..=b'Z' { + let letter = format!("{}:", c as char); + let wide: Vec = letter.encode_utf16().chain(std::iter::once(0)).collect(); + + // SAFETY: `wide` is a valid null-terminated wide string; `buf` is a + // valid mutable slice. The function writes at most `buf.len()` u16s. + let n = unsafe { QueryDosDeviceW(PCWSTR(wide.as_ptr()), Some(&mut buf)) }; + if n == 0 { + continue; + } + + // Result is a sequence of null-terminated strings ending in a double + // null. We only care about the first entry. + let end = buf + .iter() + .take(n as usize) + .position(|&w| w == 0) + .unwrap_or(n as usize); + let device = String::from_utf16_lossy(&buf[..end]); + + // QueryDosDeviceW was invoked for a DOS drive letter, so every + // returned target is a drive-backed namespace worth mapping (for + // example HarddiskVolume, CdRom, or a redirector-backed drive). + if !device.is_empty() { + out.push((letter, device)); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_device_path_returns_none() { + assert!(to_user_visible(r"C:\already\user\form").is_none()); + assert!(to_user_visible(r"\REGISTRY\Machine\SOFTWARE\Foo").is_none()); + assert!(to_user_visible("").is_none()); + } + + #[test] + fn dos_devices_prefix_is_stripped() { + assert_eq!( + to_user_visible(r"\??\C:\data\test\bin\").as_deref(), + Some(r"C:\data\test\bin\") + ); + assert_eq!(to_user_visible(r"\??\C:\").as_deref(), Some(r"C:\")); + } + + #[test] + fn dos_devices_unc_prefix_becomes_unc_path() { + assert_eq!( + to_user_visible(r"\??\UNC\server\share\file.txt").as_deref(), + Some(r"\\server\share\file.txt") + ); + } + + #[test] + fn win32_device_prefixes_are_normalized() { + for path in [r"\\?\C:\data\file.txt", r"\\.\C:\data\file.txt"] { + assert_eq!(to_user_visible(path).as_deref(), Some(r"C:\data\file.txt")); + } + + for path in [ + r"\\?\UNC\server\share\file.txt", + r"\\?\unc\server\share\file.txt", + r"\\.\UNC\server\share\file.txt", + ] { + assert_eq!( + to_user_visible(path).as_deref(), + Some(r"\\server\share\file.txt") + ); + } + } + + #[test] + fn mup_path_becomes_unc_path() { + assert_eq!( + to_user_visible(r"\Device\Mup\server\share\file.txt").as_deref(), + Some(r"\\server\share\file.txt") + ); + assert_eq!( + to_user_visible(r"\Device\Mup\;LanmanRedirector\server\share\file.txt").as_deref(), + Some(r"\\server\share\file.txt") + ); + assert_eq!( + to_user_visible( + r"\Device\Mup\;LanmanRedirector\;Z:0000000000001234\server\share\file.txt" + ) + .as_deref(), + Some(r"\\server\share\file.txt") + ); + assert_eq!( + to_user_visible(r"\Device\Mup\DfsClient\;N:0000000000001234\server\share\file.txt") + .as_deref(), + Some(r"\\server\share\file.txt") + ); + } + + #[test] + fn recognizes_absolute_user_visible_paths() { + assert!(is_user_visible_absolute(r"C:\data\file.txt")); + assert!(is_user_visible_absolute(r"\\server\share\file.txt")); + assert!(!is_user_visible_absolute(r"\\server\pipe\name")); + assert!(!is_user_visible_absolute(r"\Device\Unknown\file.txt")); + assert!(!is_user_visible_absolute(r"relative\file.txt")); + } + + #[test] + fn drive_map_populates() { + // On any Windows machine running tests there is at least one volume + // (the system drive). Verifies QueryDosDeviceW works and our parser + // accepts at least one entry. + let map = rebuild_drive_map_for_tests(); + assert!( + !map.is_empty(), + "drive map should have at least one DOS drive entry" + ); + } + + #[test] + fn maps_non_hard_disk_drive_devices() { + let map = vec![("D:".to_string(), r"\Device\CdRom0".to_string())]; + assert_eq!( + map_device_path(r"\Device\CdRom0\setup.exe", &map).as_deref(), + Some(r"D:\setup.exe") + ); + } + + #[test] + fn canonicalizes_system_drive_paths() { + let map = rebuild_drive_map_for_tests(); + if let Some((letter, kernel_prefix)) = map.first() { + let synthetic = format!(r"{kernel_prefix}\Windows\System32\drivers\etc\hosts"); + let canon = to_user_visible(&synthetic).expect("should canonicalize"); + assert_eq!( + canon, + format!(r"{letter}\Windows\System32\drivers\etc\hosts") + ); + } + } + + #[test] + fn device_prefix_requires_component_boundary() { + let map = rebuild_drive_map_for_tests(); + if let Some((_, kernel_prefix)) = map.first() { + let false_prefix = format!("{kernel_prefix}0\\Windows"); + assert!( + to_user_visible(&false_prefix).is_none(), + "{kernel_prefix} must not match {false_prefix}" + ); + } + } +} diff --git a/src/backends/learning_mode/windows/src/tdh_decode.rs b/src/backends/learning_mode/windows/src/tdh_decode.rs new file mode 100644 index 00000000..8b7bb22b --- /dev/null +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -0,0 +1,738 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! ETW event-record TDH decoder + property formatter. +//! +//! Turns raw `EVENT_RECORD` payloads into [`DecodedEventParts`] (a flat +//! `(name, value)` list) that the [`crate::extractors`] operate on. Only +//! the `InType`s we need for the learning-mode denial events are wired up; +//! the rest fall back to a textual placeholder so offset arithmetic stays +//! consistent without wasting cycles on unsupported encodings. + +use windows::Win32::System::Diagnostics::Etw::{ + TdhGetEventInformation, EVENT_PROPERTY_INFO, EVENT_RECORD, TRACE_EVENT_INFO, +}; + +use crate::extractors::DecodedEventParts; + +// TDH InType constants from evntrace.h / tdh.h. +const TDH_INTYPE_UNICODESTRING: u16 = 1; +const TDH_INTYPE_ANSISTRING: u16 = 2; +const TDH_INTYPE_INT8: u16 = 3; +const TDH_INTYPE_UINT8: u16 = 4; +const TDH_INTYPE_INT16: u16 = 5; +const TDH_INTYPE_UINT16: u16 = 6; +const TDH_INTYPE_INT32: u16 = 7; +const TDH_INTYPE_UINT32: u16 = 8; +const TDH_INTYPE_INT64: u16 = 9; +const TDH_INTYPE_UINT64: u16 = 10; +const TDH_INTYPE_BOOLEAN: u16 = 13; +const TDH_INTYPE_POINTER: u16 = 16; +const TDH_INTYPE_SID: u16 = 19; +const TDH_INTYPE_HEXINT32: u16 = 20; +const TDH_INTYPE_HEXINT64: u16 = 21; +const PROPERTY_STRUCT: i32 = 0x1; +const PROPERTY_PARAM_LENGTH: i32 = 0x2; +const PROPERTY_PARAM_COUNT: i32 = 0x4; +const MAX_PROPERTY_ELEMENTS: usize = 4096; +const EVENT_HEADER_FLAG_32_BIT_HEADER: u16 = 0x20; + +struct TdhInfoBuffer { + storage: Vec>, + len: usize, +} + +impl TdhInfoBuffer { + fn new(len: usize) -> Self { + let element_size = std::mem::size_of::(); + let element_count = len.div_ceil(element_size).max(1); + let mut storage = Vec::with_capacity(element_count); + storage.resize_with(element_count, std::mem::MaybeUninit::uninit); + // SAFETY: `storage` owns `element_count` writable elements. Zeroing + // them makes the complete byte view initialized before TDH fills it. + unsafe { + std::ptr::write_bytes(storage.as_mut_ptr(), 0, element_count); + } + Self { storage, len } + } + + fn as_mut_ptr(&mut self) -> *mut TRACE_EVENT_INFO { + self.storage.as_mut_ptr().cast() + } + + fn as_bytes(&self) -> &[u8] { + // SAFETY: `new` zero-initializes the allocation, and `len` never + // exceeds its capacity in bytes. The storage alignment is that of + // `TRACE_EVENT_INFO`, while this view is used only for byte offsets. + unsafe { std::slice::from_raw_parts(self.storage.as_ptr().cast(), self.len) } + } + + #[cfg(test)] + fn as_bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: same allocation and bounds as `as_bytes`, with exclusive + // access through `&mut self`. + unsafe { std::slice::from_raw_parts_mut(self.storage.as_mut_ptr().cast(), self.len) } + } +} + +/// Decodes an `EVENT_RECORD` into `DecodedEventParts`. +/// +/// Returns `None` when TDH can't describe the event (rare — usually +/// indicates a corrupted or unknown event). +/// +/// # Safety +/// `event_record` must point to a valid `EVENT_RECORD` provided by the +/// ETW callback; the caller must not retain references to its fields +/// after the callback returns. +pub unsafe fn decode_event_parts( + event_record: *mut EVENT_RECORD, +) -> Result { + let mut buf_size: u32 = 0; + // First call: discover required buffer size. ERROR_INSUFFICIENT_BUFFER = 122. + let status = unsafe { TdhGetEventInformation(event_record, None, None, &mut buf_size) }; + if status != 122 { + return Err(format!( + "TdhGetEventInformation(size) failed with Win32 error {status}" + )); + } + + let mut buffer = TdhInfoBuffer::new(buf_size as usize); + let info_ptr = buffer.as_mut_ptr(); + let status = + unsafe { TdhGetEventInformation(event_record, None, Some(info_ptr), &mut buf_size) }; + if status != 0 { + return Err(format!( + "TdhGetEventInformation(data) failed with Win32 error {status}" + )); + } + + let info = unsafe { &*info_ptr }; + + let header = unsafe { (*event_record).EventHeader }; + let event_id = header.EventDescriptor.Id; + let pointer_size = pointer_size_from_header_flags(header.Flags); + let props = decode_properties(buffer.as_bytes(), info, event_record, pointer_size)?; + + Ok(DecodedEventParts { + provider: header.ProviderId, + event_id, + props, + }) +} + +fn decode_properties( + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + event_record: *mut EVENT_RECORD, + pointer_size: usize, +) -> Result, String> { + // SAFETY: caller passes a valid EVENT_RECORD; the field accesses + // are reads of POD fields. + let event = unsafe { &*event_record }; + let user_data = event.UserData as *const u8; + let user_data_len = event.UserDataLength as usize; + + if user_data.is_null() || user_data_len == 0 { + return Ok(Vec::new()); + } + + let property_count = info.PropertyCount as usize; + let prop_count = info.TopLevelPropertyCount as usize; + let mut results = Vec::with_capacity(prop_count); + let mut numeric_values = vec![None; property_count]; + let mut offset: usize = 0; + let context = PropertyDecodeContext { + info_buf, + info, + user_data, + user_data_len, + pointer_size, + }; + + for i in 0..prop_count { + results.extend(decode_property( + i, + &context, + &mut offset, + &mut numeric_values, + )?); + } + + Ok(results) +} + +struct PropertyDecodeContext<'a> { + info_buf: &'a [u8], + info: &'a TRACE_EVENT_INFO, + user_data: *const u8, + user_data_len: usize, + pointer_size: usize, +} + +fn decode_property( + index: usize, + context: &PropertyDecodeContext<'_>, + offset: &mut usize, + numeric_values: &mut [Option], +) -> Result, String> { + if index >= context.info.PropertyCount as usize { + return Err(format!("property index {index} is out of range")); + } + let prop_info = event_property_info(context.info, index); + let prop_name = wide_str_at(context.info_buf, prop_info.NameOffset) + .unwrap_or_else(|| format!("prop{index}")); + let flags = prop_info.Flags.0; + let count = resolve_property_count(prop_info, flags, numeric_values, index)?; + if count > MAX_PROPERTY_ELEMENTS { + return Err(format!( + "property '{prop_name}' count {count} exceeds limit {MAX_PROPERTY_ELEMENTS}" + )); + } + + if flags & PROPERTY_STRUCT != 0 { + let start_index = unsafe { prop_info.Anonymous1.structType.StructStartIndex } as usize; + let member_count = unsafe { prop_info.Anonymous1.structType.NumOfStructMembers } as usize; + let mut values = vec![(prop_name, "".to_string())]; + for _ in 0..count { + for child_index in start_index..start_index + member_count { + values.extend(decode_property( + child_index, + context, + offset, + numeric_values, + )?); + } + } + return Ok(values); + } + + let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; + let declared_length = if flags & PROPERTY_PARAM_LENGTH != 0 { + let length_index = unsafe { prop_info.Anonymous3.lengthPropertyIndex } as usize; + resolved_metadata(numeric_values, length_index, "length", index)? + } else { + (unsafe { prop_info.Anonymous3.length }) as usize + }; + + let mut values = Vec::with_capacity(count.min(available_element_bound( + in_type, + context.user_data_len.saturating_sub(*offset), + context.pointer_size, + ))); + let mut numeric_value = None; + for _ in 0..count { + let remaining = context.user_data_len.saturating_sub(*offset); + if remaining == 0 { + return Err(format!("property '{prop_name}' exceeds the event payload")); + } + let data_ptr = if remaining > 0 { + unsafe { context.user_data.add(*offset) } + } else { + std::ptr::null() + }; + let (value, consumed) = format_property_value_with_pointer_size( + in_type, + declared_length, + data_ptr, + remaining, + context.pointer_size, + ); + if consumed == 0 && remaining > 0 { + return Err(format!( + "property '{prop_name}' has unsupported variable length" + )); + } + + *offset = offset.saturating_add(consumed); + if count == 1 { + numeric_value = parse_numeric_metadata(&value); + } + values.push(value); + } + numeric_values[index] = numeric_value; + + let rendered = if values.len() == 1 { + values.pop().unwrap_or_default() + } else { + format!("[{}]", values.join(", ")) + }; + Ok(vec![(prop_name, rendered)]) +} + +fn resolve_property_count( + prop_info: &EVENT_PROPERTY_INFO, + flags: i32, + numeric_values: &[Option], + property_index: usize, +) -> Result { + if flags & PROPERTY_PARAM_COUNT != 0 { + let count_index = unsafe { prop_info.Anonymous2.countPropertyIndex } as usize; + resolved_metadata(numeric_values, count_index, "count", property_index) + } else { + let count = unsafe { prop_info.Anonymous2.count } as usize; + Ok(count.max(1)) + } +} + +fn pointer_size_from_header_flags(flags: u16) -> usize { + if flags & EVENT_HEADER_FLAG_32_BIT_HEADER != 0 { + 4 + } else { + 8 + } +} + +fn available_element_bound(in_type: u16, available: usize, pointer_size: usize) -> usize { + let element_size = match in_type { + TDH_INTYPE_INT8 | TDH_INTYPE_UINT8 => 1, + TDH_INTYPE_INT16 | TDH_INTYPE_UINT16 => 2, + TDH_INTYPE_INT32 | TDH_INTYPE_UINT32 | TDH_INTYPE_BOOLEAN | TDH_INTYPE_HEXINT32 => 4, + TDH_INTYPE_POINTER => pointer_size, + TDH_INTYPE_INT64 | TDH_INTYPE_UINT64 | TDH_INTYPE_HEXINT64 => 8, + _ => 1, + }; + (available / element_size).max(1) +} + +fn event_property_info(info: &TRACE_EVENT_INFO, index: usize) -> &EVENT_PROPERTY_INFO { + unsafe { + let base = std::ptr::addr_of!(info.EventPropertyInfoArray) as *const EVENT_PROPERTY_INFO; + &*base.add(index) + } +} + +fn resolved_metadata( + numeric_values: &[Option], + metadata_index: usize, + kind: &str, + property_index: usize, +) -> Result { + numeric_values + .get(metadata_index) + .and_then(|value| *value) + .ok_or_else(|| { + format!( + "property {property_index} references unresolved {kind} property {metadata_index}" + ) + }) +} + +fn parse_numeric_metadata(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .and_then(|hex| usize::from_str_radix(hex, 16).ok()) + .or_else(|| value.parse().ok()) +} + +fn format_property_value_with_pointer_size( + in_type: u16, + declared_length: usize, + data: *const u8, + available: usize, + pointer_size: usize, +) -> (String, usize) { + if data.is_null() || available == 0 { + return ("".to_string(), 0); + } + + match in_type { + TDH_INTYPE_UNICODESTRING => { + let byte_len = if declared_length > 0 { + declared_length.min(available) + } else { + available + }; + let max_wchars = byte_len / 2; + // SAFETY: data is valid for `available` bytes; byte_len is bounded + // by available. Decode from byte pairs because ETW does not + // guarantee payload alignment. + let bytes = unsafe { std::slice::from_raw_parts(data, byte_len) }; + let mut wchars = Vec::with_capacity(max_wchars); + let mut terminator_index = None; + for (index, chunk) in bytes.chunks_exact(2).enumerate() { + let wchar = u16::from_le_bytes([chunk[0], chunk[1]]); + if wchar == 0 { + terminator_index = Some(index); + break; + } + wchars.push(wchar); + } + let s = String::from_utf16_lossy(&wchars); + // Include the null terminator in consumed bytes when present. + let consumed = if declared_length > 0 { + byte_len + } else { + terminator_index.map_or(max_wchars * 2, |index| (index + 1) * 2) + }; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_ANSISTRING => { + let byte_len = if declared_length > 0 { + declared_length.min(available) + } else { + available + }; + let bytes = unsafe { std::slice::from_raw_parts(data, byte_len) }; + let len = bytes.iter().position(|&b| b == 0).unwrap_or(byte_len); + let s = String::from_utf8_lossy(&bytes[..len]); + let consumed = if declared_length > 0 { + byte_len + } else { + (len + 1).min(available) + }; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_INT8 if available >= 1 => { + let v = unsafe { *data } as i8; + (v.to_string(), 1) + } + TDH_INTYPE_UINT8 if available >= 1 => { + let v = unsafe { *data }; + (v.to_string(), 1) + } + TDH_INTYPE_BOOLEAN if available >= 4 => { + // SAFETY: data points to >=4 valid bytes; read_unaligned because + // ETW payload alignment is not guaranteed. + let v = unsafe { (data.cast::()).read_unaligned() }; + (if v != 0 { "true" } else { "false" }.to_string(), 4) + } + TDH_INTYPE_INT16 if available >= 2 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 2, + ), + TDH_INTYPE_UINT16 if available >= 2 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 2, + ), + TDH_INTYPE_INT32 if available >= 4 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 4, + ), + TDH_INTYPE_UINT32 if available >= 4 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 4, + ), + TDH_INTYPE_HEXINT32 if available >= 4 => ( + format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), + 4, + ), + TDH_INTYPE_INT64 if available >= 8 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 8, + ), + TDH_INTYPE_UINT64 if available >= 8 => ( + unsafe { (data.cast::()).read_unaligned() }.to_string(), + 8, + ), + TDH_INTYPE_HEXINT64 if available >= 8 => ( + format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), + 8, + ), + TDH_INTYPE_POINTER if pointer_size == 4 && available >= 4 => ( + format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), + 4, + ), + TDH_INTYPE_POINTER if pointer_size == 8 && available >= 8 => ( + format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), + 8, + ), + TDH_INTYPE_SID if available >= 8 => format_sid(data, available), + // Unknown / unsupported InType: emit a placeholder. Consume the + // declared length when one is given so offset arithmetic stays + // consistent; otherwise consume zero. + _ => ("".to_string(), declared_length.min(available)), + } +} + +#[cfg(test)] +fn format_property_value( + in_type: u16, + declared_length: usize, + data: *const u8, + available: usize, +) -> (String, usize) { + format_property_value_with_pointer_size(in_type, declared_length, data, available, 8) +} + +fn format_sid(data: *const u8, available: usize) -> (String, usize) { + let header = unsafe { std::slice::from_raw_parts(data, available.min(8)) }; + if header.len() < 8 { + return ("".to_string(), available); + } + let sub_authority_count = header[1] as usize; + let length = 8usize.saturating_add(sub_authority_count.saturating_mul(4)); + if length > available { + return ("".to_string(), available); + } + let bytes = unsafe { std::slice::from_raw_parts(data, length) }; + let authority = bytes[2..8] + .iter() + .fold(0u64, |value, byte| (value << 8) | u64::from(*byte)); + let mut sid = format!("S-{}-{authority}", bytes[0]); + for index in 0..sub_authority_count { + let start = 8 + index * 4; + let value = u32::from_le_bytes([ + bytes[start], + bytes[start + 1], + bytes[start + 2], + bytes[start + 3], + ]); + sid.push_str(&format!("-{value}")); + } + (sid, length) +} + +fn wide_str_at(buf: &[u8], offset: u32) -> Option { + let offset = offset as usize; + if offset == 0 || offset >= buf.len() { + return None; + } + let slice = &buf[offset..]; + // The buffer is u8-aligned but the names are u16-aligned by + // construction (TDH places them at even offsets). Iterate u16 + // pairs until null terminator or end of buffer. + let mut end = slice.len(); + let mut i = 0; + while i + 1 < slice.len() { + let lo = slice[i] as u16; + let hi = slice[i + 1] as u16; + let wchar = lo | (hi << 8); + if wchar == 0 { + end = i; + break; + } + i += 2; + } + let trimmed = &slice[..end]; + // Build a Vec from byte pairs. + let wchars: Vec = trimmed + .chunks_exact(2) + .map(|p| (p[0] as u16) | ((p[1] as u16) << 8)) + .collect(); + if wchars.is_empty() { + None + } else { + Some(String::from_utf16_lossy(&wchars)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utf16_bytes(value: &str) -> Vec { + value + .encode_utf16() + .chain([0]) + .flat_map(u16::to_le_bytes) + .collect() + } + + #[test] + fn tdh_info_buffer_is_aligned_and_initialized() { + let mut buffer = TdhInfoBuffer::new(std::mem::size_of::() + 7); + + assert_eq!( + buffer + .as_mut_ptr() + .align_offset(std::mem::align_of::()), + 0 + ); + assert!(buffer.as_bytes().iter().all(|byte| *byte == 0)); + } + + #[test] + fn decode_property_surfaces_struct_children() { + let struct_name = utf16_bytes("AccessCheck"); + let child_name = utf16_bytes("Denied"); + let metadata_len = + std::mem::size_of::() + std::mem::size_of::(); + let struct_name_offset = metadata_len; + let child_name_offset = struct_name_offset + struct_name.len(); + let mut buffer = TdhInfoBuffer::new(child_name_offset + child_name.len()); + buffer.as_bytes_mut()[struct_name_offset..child_name_offset].copy_from_slice(&struct_name); + buffer.as_bytes_mut()[child_name_offset..].copy_from_slice(&child_name); + + let info = unsafe { &mut *buffer.as_mut_ptr() }; + info.PropertyCount = 2; + let properties = + std::ptr::addr_of_mut!(info.EventPropertyInfoArray) as *mut EVENT_PROPERTY_INFO; + unsafe { + (*properties).NameOffset = struct_name_offset as u32; + (*properties).Flags.0 = PROPERTY_STRUCT; + (*properties).Anonymous1.structType.StructStartIndex = 1; + (*properties).Anonymous1.structType.NumOfStructMembers = 1; + (*properties).Anonymous2.count = 1; + + let child = properties.add(1); + (*child).NameOffset = child_name_offset as u32; + (*child).Anonymous1.nonStructType.InType = TDH_INTYPE_UINT32; + (*child).Anonymous2.count = 1; + (*child).Anonymous3.length = 4; + } + + let payload = 1u32.to_le_bytes(); + let info = unsafe { &*buffer.as_mut_ptr() }; + let context = PropertyDecodeContext { + info_buf: buffer.as_bytes(), + info, + user_data: payload.as_ptr(), + user_data_len: payload.len(), + pointer_size: 8, + }; + let mut offset = 0; + let mut numeric_values = vec![None; 2]; + + let properties = decode_property(0, &context, &mut offset, &mut numeric_values).unwrap(); + + assert_eq!( + properties, + vec![ + ("AccessCheck".to_string(), "".to_string()), + ("Denied".to_string(), "1".to_string()), + ] + ); + assert_eq!(offset, payload.len()); + } + + #[test] + fn wide_str_at_reads_utf16_until_null() { + // "hi\0extra" as UTF-16 LE: 68 00 69 00 00 00 65 00 78 00 74 00 72 00 61 00 + let buf = [ + 0u8, 0, // padding at offset 0 + b'h', 0, b'i', 0, 0, 0, b'e', 0, b'x', 0, + ]; + assert_eq!(wide_str_at(&buf, 2).as_deref(), Some("hi")); + } + + #[test] + fn wide_str_at_out_of_bounds_returns_none() { + let buf = [0u8; 4]; + assert!(wide_str_at(&buf, 100).is_none()); + assert!(wide_str_at(&buf, 0).is_none()); + } + + #[test] + fn format_property_value_unicode_string_extracts_content() { + let s = "hello"; + let mut bytes: Vec = s.encode_utf16().flat_map(|w| w.to_le_bytes()).collect(); + bytes.extend_from_slice(&[0, 0]); // null terminator + let (val, consumed) = + format_property_value(TDH_INTYPE_UNICODESTRING, 0, bytes.as_ptr(), bytes.len()); + assert_eq!(val, "\"hello\""); + assert_eq!(consumed, bytes.len()); // 5 chars + null = 12 bytes + } + + #[test] + fn format_property_value_unicode_string_accepts_unaligned_data() { + let encoded: Vec = "hello" + .encode_utf16() + .flat_map(|wchar| wchar.to_le_bytes()) + .chain([0, 0]) + .collect(); + let mut storage = vec![0u8; encoded.len() + 1]; + let base = storage.as_ptr() as usize; + let offset = usize::from(base.is_multiple_of(std::mem::align_of::())); + storage[offset..offset + encoded.len()].copy_from_slice(&encoded); + let data = unsafe { storage.as_ptr().add(offset) }; + assert_ne!((data as usize) % std::mem::align_of::(), 0); + + let (value, consumed) = + format_property_value(TDH_INTYPE_UNICODESTRING, 0, data, encoded.len()); + + assert_eq!(value, "\"hello\""); + assert_eq!(consumed, encoded.len()); + } + + #[test] + fn format_property_value_unicode_string_honors_fixed_length() { + let bytes: Vec = "abc".encode_utf16().flat_map(|w| w.to_le_bytes()).collect(); + let (val, consumed) = + format_property_value(TDH_INTYPE_UNICODESTRING, 4, bytes.as_ptr(), bytes.len()); + assert_eq!(val, "\"ab\""); + assert_eq!(consumed, 4); + } + + #[test] + fn format_property_value_uint32_reads_little_endian() { + let bytes = 0xCAFE_BABEu32.to_le_bytes(); + let (val, consumed) = + format_property_value(TDH_INTYPE_UINT32, 4, bytes.as_ptr(), bytes.len()); + assert_eq!(val, "3405691582"); + assert_eq!(consumed, 4); + } + + #[test] + fn pointer_width_follows_event_header_flags() { + assert_eq!(pointer_size_from_header_flags(0), 8); + assert_eq!( + pointer_size_from_header_flags(EVENT_HEADER_FLAG_32_BIT_HEADER), + 4 + ); + } + + #[test] + fn format_property_value_pointer_supports_32_and_64_bit_events() { + let pointer32 = 0xCAFE_BABEu32.to_le_bytes(); + let (value, consumed) = format_property_value_with_pointer_size( + TDH_INTYPE_POINTER, + 0, + pointer32.as_ptr(), + pointer32.len(), + 4, + ); + assert_eq!(value, "0xcafebabe"); + assert_eq!(consumed, 4); + + let pointer64 = 0xCAFE_BABE_DEAD_BEEFu64.to_le_bytes(); + let (value, consumed) = format_property_value_with_pointer_size( + TDH_INTYPE_POINTER, + 0, + pointer64.as_ptr(), + pointer64.len(), + 8, + ); + assert_eq!(value, "0xcafebabedeadbeef"); + assert_eq!(consumed, 8); + } + + #[test] + fn format_property_value_unsupported_consumes_declared_length() { + let bytes = [0u8; 4]; + let (val, consumed) = format_property_value(0xFFFF, 4, bytes.as_ptr(), bytes.len()); + assert_eq!(val, ""); + assert_eq!(consumed, 4); + } + + #[test] + fn format_property_value_null_data_returns_no_data() { + let (val, consumed) = format_property_value(TDH_INTYPE_UINT32, 4, std::ptr::null(), 0); + assert_eq!(val, ""); + assert_eq!(consumed, 0); + } + + #[test] + fn numeric_metadata_parses_decimal_and_hex() { + assert_eq!(parse_numeric_metadata("12"), Some(12)); + assert_eq!(parse_numeric_metadata("0x10"), Some(16)); + assert_eq!(parse_numeric_metadata("\"7\""), Some(7)); + assert_eq!(parse_numeric_metadata("not-a-number"), None); + } + + #[test] + fn format_property_value_sid_decodes_identifier() { + // S-1-15-3-1 + let bytes = [ + 1, 2, // revision, sub-authority count + 0, 0, 0, 0, 0, 15, // identifier authority + 3, 0, 0, 0, // sub-authority 0 + 1, 0, 0, 0, // sub-authority 1 + ]; + let (value, consumed) = + format_property_value(TDH_INTYPE_SID, 0, bytes.as_ptr(), bytes.len()); + assert_eq!(value, "S-1-15-3-1"); + assert_eq!(consumed, bytes.len()); + } +} diff --git a/src/core/learning_mode_core/Cargo.toml b/src/core/learning_mode_core/Cargo.toml new file mode 100644 index 00000000..0f928864 --- /dev/null +++ b/src/core/learning_mode_core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "learning_mode_core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/src/core/learning_mode_core/src/analyze.rs b/src/core/learning_mode_core/src/analyze.rs new file mode 100644 index 00000000..74ff2d17 --- /dev/null +++ b/src/core/learning_mode_core/src/analyze.rs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The decode abstraction: turn a platform-native capture source into the +//! cross-platform [`DeniedResource`] model. +//! +//! Each backend implements [`DenialAnalyzer`] over its own capture format. +//! The Windows backend (`learning_mode_windows`) implements it over a +//! sealed ETW trace (`.etl`); a future Linux backend would implement it +//! over its own source. Keeping the trait in this cross-platform crate +//! lets the runner and tests depend on the abstraction rather than any +//! one OS decoder, and lets tests substitute a fake. + +use std::path::Path; + +use thiserror::Error; + +use crate::model::DeniedResource; + +/// Result of decoding a capture source into bounded, de-duplicated denials. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnalysisResult { + /// Unique denials retained by the analyzer in first-seen order. + pub denials: Vec, + /// Whether additional unique denials were observed after the result bound + /// was reached. + pub denied_resources_truncated: bool, +} + +impl AnalysisResult { + /// Creates a complete, non-truncated result. + #[must_use] + pub fn complete(denials: Vec) -> Self { + Self { + denials, + denied_resources_truncated: false, + } + } +} + +/// Failure modes when analysing a capture source into denials. +#[derive(Debug, Error)] +pub enum AnalyzeError { + /// The capture source could not be opened (missing file, permissions). + #[error("failed to open capture source '{path}': {source}")] + Open { + /// The source path that could not be opened. + path: String, + /// The underlying I/O error. + source: std::io::Error, + }, + + /// The source was opened but could not be decoded into denials. + #[error("failed to decode capture source: {0}")] + Decode(String), + + /// Analysis is not available on this platform / build (e.g. the + /// decoder is Windows-only and this is a non-Windows target). + #[error("capture analysis is not supported on this platform")] + Unsupported, +} + +/// Decodes a platform-native capture source into de-duplicated denials. +/// +/// Implementors return bounded unique `(path, accessType)` observations and +/// whether additional unique records were truncated; the caller wraps them with a +/// [`crate::summary::DenialSummary`] and emits an RFC 7464 JSON text sequence via +/// [`crate::emit`]. +pub trait DenialAnalyzer { + /// Analyses the capture at `source_path`, returning its bounded denial + /// result. + /// + /// # Errors + /// + /// Returns [`AnalyzeError`] if the source cannot be opened, cannot be + /// decoded, or analysis is unsupported on this platform. + fn analyze(&self, source_path: &Path) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{AccessType, ResourceType}; + + /// A trivial analyzer returning a fixed set, proving the trait is + /// object-safe and usable behind a `dyn` reference. + struct FakeAnalyzer(Vec); + + impl DenialAnalyzer for FakeAnalyzer { + fn analyze(&self, _source_path: &Path) -> Result { + Ok(AnalysisResult::complete(self.0.clone())) + } + } + + #[test] + fn analyzer_is_object_safe_and_returns_denials() { + let denials = vec![DeniedResource { + path: r"C:\a".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 1, + filetime: 2, + }]; + let analyzer: Box = Box::new(FakeAnalyzer(denials.clone())); + let got = analyzer.analyze(Path::new("ignored.etl")).unwrap(); + assert_eq!(got.denials, denials); + assert!(!got.denied_resources_truncated); + } + + #[test] + fn analyze_error_messages_are_meaningful() { + let err = AnalyzeError::Open { + path: "x.etl".to_string(), + source: std::io::Error::new(std::io::ErrorKind::NotFound, "nope"), + }; + assert!(err.to_string().contains("x.etl")); + assert!(AnalyzeError::Unsupported + .to_string() + .contains("not supported")); + } +} diff --git a/src/core/learning_mode_core/src/emit.rs b/src/core/learning_mode_core/src/emit.rs new file mode 100644 index 00000000..c14e7c87 --- /dev/null +++ b/src/core/learning_mode_core/src/emit.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! RFC 7464 JSON text sequence emitter for captureDenials output files. +//! +//! The output file follows the [RFC 7464] "JSON Text Sequences" framing: +//! every record is preceded by an ASCII Record Separator (`0x1E`) and +//! terminated by a line feed (`0x0A`): +//! +//! ```text +//! {"type":"denial", ...} +//! {"type":"denial", ...} +//! {"type":"summary", ...} +//! ``` +//! +//! The `0x1E` prefix is load-bearing: it effectively never appears in +//! legitimate workload output, so a consumer can split on it to reliably +//! separate MXC envelopes from any interleaved bytes. The summary frame +//! terminates the stream for one `wxc-exec` invocation. +//! +//! This module is transport-agnostic — it writes to any [`io::Write`], +//! so the same code path serves the on-disk output file and in-memory +//! test buffers. +//! +//! [RFC 7464]: https://www.rfc-editor.org/rfc/rfc7464 + +use std::io::{self, Write}; + +use crate::frame::DenialFrame; +use crate::model::DeniedResource; +use crate::summary::DenialSummary; + +/// ASCII Record Separator (`0x1E`), prefixed to every framed record. +pub const RECORD_SEPARATOR: u8 = 0x1E; + +/// Writes one framed record: `RS` + compact JSON + `LF`. +/// +/// # Errors +/// +/// Returns any [`io::Error`] from the underlying writer, or a +/// serialisation error surfaced as [`io::ErrorKind::InvalidData`]. +pub fn write_frame(writer: &mut W, frame: &DenialFrame) -> io::Result<()> { + let json = serde_json::to_vec(frame).map_err(io::Error::other)?; + writer.write_all(&[RECORD_SEPARATOR])?; + writer.write_all(&json)?; + writer.write_all(b"\n") +} + +/// Writes a complete captureDenials stream: one `denial` frame per +/// resource, followed by exactly one terminating `summary` frame, then +/// flushes the writer. +/// +/// The caller is responsible for having already de-duplicated +/// `resources`; `summary.total_denials` should equal `resources.len()` +/// for a non-truncated capture. +/// +/// # Errors +/// +/// Returns the first [`io::Error`] encountered while writing or flushing. +pub fn write_stream( + writer: &mut W, + resources: &[DeniedResource], + summary: &DenialSummary, +) -> io::Result<()> { + for resource in resources { + let frame = DenialFrame::Denial(resource.clone()); + write_frame(writer, &frame)?; + } + write_frame(writer, &DenialFrame::Summary(*summary))?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{AccessType, ResourceType}; + + fn sample(path: &str) -> DeniedResource { + DeniedResource { + path: path.to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 100, + filetime: 200, + } + } + + #[test] + fn write_frame_prefixes_rs_and_terminates_lf() { + let mut buf = Vec::new(); + write_frame(&mut buf, &DenialFrame::Denial(sample(r"C:\a"))).unwrap(); + assert_eq!(buf[0], RECORD_SEPARATOR); + assert_eq!(*buf.last().unwrap(), b'\n'); + // Exactly one RS and one LF for a single record. + assert_eq!(buf.iter().filter(|&&b| b == RECORD_SEPARATOR).count(), 1); + assert_eq!(buf.iter().filter(|&&b| b == b'\n').count(), 1); + } + + #[test] + fn write_stream_emits_denials_then_single_summary() { + let resources = vec![sample(r"C:\a"), sample(r"C:\b")]; + let summary = DenialSummary::new(0, 2, false); + let mut buf = Vec::new(); + write_stream(&mut buf, &resources, &summary).unwrap(); + + let text = String::from_utf8(buf).unwrap(); + let records: Vec<&str> = text + .split(RECORD_SEPARATOR as char) + .filter(|s| !s.is_empty()) + .collect(); + assert_eq!(records.len(), 3, "2 denials + 1 summary"); + assert!(records[0].contains("\"type\":\"denial\"")); + assert!(records[1].contains("\"type\":\"denial\"")); + assert!(records[2].contains("\"type\":\"summary\"")); + assert!(records[2].contains("\"totalDenials\":2")); + } + + #[test] + fn written_stream_round_trips_by_splitting_on_rs() { + let resources = vec![sample(r"C:\a")]; + let summary = DenialSummary::new(7, 1, true); + let mut buf = Vec::new(); + write_stream(&mut buf, &resources, &summary).unwrap(); + + let text = String::from_utf8(buf).unwrap(); + let frames: Vec = text + .split(RECORD_SEPARATOR as char) + .filter(|s| !s.trim().is_empty()) + .map(|s| serde_json::from_str(s.trim()).unwrap()) + .collect(); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0], DenialFrame::Denial(sample(r"C:\a"))); + assert_eq!(frames[1], DenialFrame::Summary(summary)); + } + + #[test] + fn empty_capture_still_writes_summary() { + let summary = DenialSummary::new(0, 0, false); + let mut buf = Vec::new(); + write_stream(&mut buf, &[], &summary).unwrap(); + let text = String::from_utf8(buf).unwrap(); + assert!(text.contains("\"type\":\"summary\"")); + assert_eq!(text.matches('\n').count(), 1); + } +} diff --git a/src/core/learning_mode_core/src/frame.rs b/src/core/learning_mode_core/src/frame.rs new file mode 100644 index 00000000..2505caae --- /dev/null +++ b/src/core/learning_mode_core/src/frame.rs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The self-describing wire records that make up a captureDenials output +//! stream. +//! +//! A captureDenials output file is a sequence of JSON records, each +//! tagged with a `type` discriminator so a consumer can dispatch without +//! positional assumptions: +//! +//! ```text +//! {"type":"denial","path":"...","resourceType":"file","accessType":"read","pid":123,"filetime":"..."} +//! {"type":"denial", ...} +//! {"type":"summary","exitCode":0,"totalDenials":2,"deniedResourcesTruncated":false} +//! ``` +//! +//! [`DenialFrame`] is an internally-tagged enum over the two record +//! kinds, so `serde` handles the `type` field and consumers get an +//! exhaustive match. The framing bytes (RFC 7464 record separators) are +//! added by [`crate::emit`]; this module only defines the JSON objects. + +use serde::{Deserialize, Serialize}; + +use crate::model::DeniedResource; +use crate::summary::DenialSummary; + +/// One record in a captureDenials output stream. +/// +/// Serialises with an internal `type` tag (`"denial"` / `"summary"`) +/// flattened alongside the payload fields, matching the on-disk JSON sequence +/// contract consumers parse. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum DenialFrame { + /// A single denied resource observation. + Denial(DeniedResource), + /// The terminating summary; exactly one per stream, written last. + Summary(DenialSummary), +} + +impl From for DenialFrame { + fn from(resource: DeniedResource) -> Self { + DenialFrame::Denial(resource) + } +} + +impl From for DenialFrame { + fn from(summary: DenialSummary) -> Self { + DenialFrame::Summary(summary) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{AccessType, ResourceType}; + + #[test] + fn denial_frame_carries_type_tag() { + let frame = DenialFrame::from(DeniedResource { + path: r"C:\x".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 7, + filetime: 9, + }); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.starts_with("{\"type\":\"denial\""), "got {json}"); + assert!(json.contains("\"path\":\"C:\\\\x\""), "got {json}"); + } + + #[test] + fn summary_frame_carries_type_tag() { + let frame = DenialFrame::from(DenialSummary::new(0, 1, false)); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.starts_with("{\"type\":\"summary\""), "got {json}"); + assert!(json.contains("\"totalDenials\":1"), "got {json}"); + } + + #[test] + fn frame_round_trips_through_json() { + let denial = DenialFrame::from(DeniedResource { + path: r"C:\y".to_string(), + resource_type: ResourceType::Capability, + access_type: AccessType::Unknown, + pid: 3, + filetime: 4, + }); + let parsed: DenialFrame = + serde_json::from_str(&serde_json::to_string(&denial).unwrap()).unwrap(); + assert_eq!(denial, parsed); + + let summary = DenialFrame::from(DenialSummary::new(2, 5, true)); + let parsed: DenialFrame = + serde_json::from_str(&serde_json::to_string(&summary).unwrap()).unwrap(); + assert_eq!(summary, parsed); + } +} diff --git a/src/core/learning_mode_core/src/lib.rs b/src/core/learning_mode_core/src/lib.rs new file mode 100644 index 00000000..bde6a79e --- /dev/null +++ b/src/core/learning_mode_core/src/lib.rs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Cross-platform core for the captureDenials / learning-mode pipeline. +//! +//! MXC's learning-mode capture flow has three stages: +//! +//! 1. **Capture** — a backend runs the workload under an OS learning mode +//! and seals a native trace (on Windows, an ETW `.etl`). This lives in +//! the per-OS backend crates. +//! 2. **Analyse** — the trace is decoded into cross-platform +//! [`DeniedResource`] records. The per-OS decoder implements +//! [`DenialAnalyzer`]; this crate owns the trait and the model. +//! 3. **Emit** — the records plus a terminating [`DenialSummary`] are +//! written to an RFC 7464 JSON text sequence that host applications read to +//! regenerate their sandbox policy. See [`emit`]. +//! +//! This crate is the cross-platform hinge between stages 2 and 3: it +//! defines the public [`DeniedResource`] model, the [`DenialSummary`] +//! terminator, the self-describing [`DenialFrame`] wire records, the +//! RFC 7464 JSON text sequence [`emit`]ter, and the [`DenialAnalyzer`] decode trait. +//! It carries no OS-specific code so the wire format never encodes a +//! platform assumption. +//! +//! ## Mode caveat +//! +//! What a capture contains depends on the active OS learning mode. +//! File/path, UI, and capability denials may be recorded under both +//! `learningMode` (`block`) and `permissiveLearningMode` (`allow`). The +//! concrete ETW event shape differs by mode, and records without a decoded +//! resource identifier are omitted rather than emitted as empty resources. + +#![deny(missing_docs)] + +pub mod analyze; +pub mod emit; +pub mod frame; +pub mod model; +pub mod summary; + +pub use analyze::{AnalysisResult, AnalyzeError, DenialAnalyzer}; +pub use emit::{write_frame, write_stream, RECORD_SEPARATOR}; +pub use frame::DenialFrame; +pub use model::{AccessType, DedupKey, DeniedResource, ResourceType}; +pub use summary::DenialSummary; diff --git a/src/core/learning_mode_core/src/model.rs b/src/core/learning_mode_core/src/model.rs new file mode 100644 index 00000000..a5b8f793 --- /dev/null +++ b/src/core/learning_mode_core/src/model.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Public data model for the captureDenials / learning-mode pipeline. +//! +//! [`DeniedResource`] is the shape every backend decoder emits, every +//! transport carries, and every SDK consumer parses. New OS backends +//! produce it from their native sources (Windows ETW today; Linux/macOS +//! later); the JSON text sequence (see [`crate::emit`]) is a framed +//! stream of these records plus a trailing [`crate::summary::DenialSummary`]. +//! +//! The types stay tiny and cross-platform so the wire format never +//! accidentally encodes a Windows-only assumption. The Windows ETL +//! decoder lives in the `learning_mode_windows` backend crate and maps +//! its ETW-intermediate events into these types. + +use serde::{Deserialize, Serialize}; + +mod decimal_u64 { + use serde::{Deserialize, Deserializer, Serializer}; + + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Decimal(String), + LegacyNumber(u64), + } + + pub fn serialize(value: &u64, serializer: S) -> Result { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + match Repr::deserialize(deserializer)? { + Repr::Decimal(value) => value.parse().map_err(serde::de::Error::custom), + Repr::LegacyNumber(value) => Ok(value), + } + } +} + +/// The kind of resource an access denial was recorded against. +/// +/// The variant set is deliberately closed and cross-platform. The +/// Windows decoder classifies ETW events into these buckets; other +/// backends map their native sources onto the same vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResourceType { + /// Filesystem path (file or directory). + File, + /// User-interface resource (clipboard, window handle, input, etc.). + Ui, + /// Network endpoint. Reserved for future network/WFP capture; the + /// current Windows backend does not yet produce this variant. + Network, + /// A named OS capability (AppContainer / brokered capability) the + /// workload was denied. Capability records may be produced under either + /// learning mode when the source event contains a decoded identifier. + Capability, + /// Unclassified denial (registry, COM, IPC, section object, etc.). + Other, +} + +/// The kind of access that was attempted and denied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AccessType { + /// Read / query access. + Read, + /// Write / create / modify / delete access. + Write, + /// Execute / traverse access. + Execute, + /// Access kind could not be determined from the source event. + Unknown, +} + +/// One denied `(path, accessType)` observation surfaced to consumers. +/// +/// A `DeniedResource` describes a single resource the sandboxed workload +/// was denied access to. Per-`(path, accessType)` de-duplication happens +/// in the decoder, so consumers can treat the emitted stream as already +/// unique. +/// +/// # Examples +/// +/// ``` +/// use learning_mode_core::{AccessType, DeniedResource, ResourceType}; +/// +/// let denial = DeniedResource { +/// path: r"C:\Users\test\secret.txt".to_string(), +/// resource_type: ResourceType::File, +/// access_type: AccessType::Read, +/// pid: 1234, +/// filetime: 132_847_890_123_456_789, +/// }; +/// let json = serde_json::to_string(&denial)?; +/// assert!(json.contains("\"resourceType\":\"file\"")); +/// # Ok::<(), serde_json::Error>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeniedResource { + /// User-visible canonicalised resource identifier. On Windows this is + /// drive-letter form (`C:\Users\...`) with NT-device-namespace + /// prefixes (`\??\`, `\Device\HarddiskVolumeN\`) already stripped by + /// the decoder. Network endpoints (when implemented) use `host:port`. + pub path: String, + + /// Type of resource (see [`ResourceType`]). + pub resource_type: ResourceType, + + /// Access type the workload was attempting (see [`AccessType`]). + pub access_type: AccessType, + + /// Process ID inside the sandbox that triggered the denial. + pub pid: u32, + + /// Kernel timestamp of the denial. On Windows this is `FILETIME` + /// (100-nanosecond intervals since 1601-01-01 UTC), copied from + /// `EVENT_RECORD.EventHeader.TimeStamp`. Other backends normalise + /// their native clocks onto the same epoch so consumers can treat + /// the field uniformly. The JSON wire format uses a decimal string so + /// JavaScript consumers do not lose precision. + #[serde(with = "decimal_u64")] + pub filetime: u64, +} + +/// De-duplication key for a denial: the `(path, accessType)` pair. +/// +/// Decoders collapse the many raw kernel access-check events a workload +/// generates (locale code re-reading the same key on every `printf`, +/// etc.) down to one record per unique pair. +pub type DedupKey = (String, AccessType); + +impl DeniedResource { + /// Returns the `(path, accessType)` de-duplication key for this + /// denial. Cloning the path is intentional so the key can outlive a + /// borrow of `self` while a decoder accumulates into a set. + #[must_use] + pub fn dedup_key(&self) -> DedupKey { + (self.path.clone(), self.access_type) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn denied_resource_serialises_camel_case() { + // Guards the wire-format contract: SDK consumers depend on the + // camelCase keys and lowercased enum strings. A future serde + // rename would silently break every downstream parser. + let r = DeniedResource { + path: r"C:\Users\test\file.txt".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 1234, + filetime: 132_847_890_123_456_789, + }; + let json = serde_json::to_string(&r).unwrap(); + assert!(json.contains("\"resourceType\":\"file\""), "got {json}"); + assert!(json.contains("\"accessType\":\"read\""), "got {json}"); + assert!( + json.contains("\"filetime\":\"132847890123456789\""), + "got {json}" + ); + } + + #[test] + fn denied_resource_round_trips_through_json() { + let r = DeniedResource { + path: r"C:\foo\bar.txt".to_string(), + resource_type: ResourceType::Capability, + access_type: AccessType::Write, + pid: 9999, + filetime: 42, + }; + let json = serde_json::to_string(&r).unwrap(); + let parsed: DeniedResource = serde_json::from_str(&json).unwrap(); + assert_eq!(r, parsed); + } + + #[test] + fn denied_resource_accepts_legacy_numeric_filetime() { + let parsed: DeniedResource = serde_json::from_str( + r#"{"path":"C:\\foo","resourceType":"file","accessType":"read","pid":1,"filetime":42}"#, + ) + .unwrap(); + assert_eq!(parsed.filetime, 42); + } + + #[test] + fn resource_type_serialises_each_variant_to_lowercase() { + for (variant, expected) in [ + (ResourceType::File, "\"file\""), + (ResourceType::Ui, "\"ui\""), + (ResourceType::Network, "\"network\""), + (ResourceType::Capability, "\"capability\""), + (ResourceType::Other, "\"other\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), expected); + } + } + + #[test] + fn access_type_serialises_each_variant_to_lowercase() { + for (variant, expected) in [ + (AccessType::Read, "\"read\""), + (AccessType::Write, "\"write\""), + (AccessType::Execute, "\"execute\""), + (AccessType::Unknown, "\"unknown\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), expected); + } + } + + #[test] + fn dedup_key_pairs_path_and_access_type() { + let r = DeniedResource { + path: r"C:\a".to_string(), + resource_type: ResourceType::File, + access_type: AccessType::Read, + pid: 1, + filetime: 1, + }; + assert_eq!(r.dedup_key(), (r"C:\a".to_string(), AccessType::Read)); + } +} diff --git a/src/core/learning_mode_core/src/summary.rs b/src/core/learning_mode_core/src/summary.rs new file mode 100644 index 00000000..a7401369 --- /dev/null +++ b/src/core/learning_mode_core/src/summary.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Terminating summary frame for a captureDenials output stream. +//! +//! Exactly one [`DenialSummary`] is written after the last +//! [`crate::model::DeniedResource`] in an RFC 7464 JSON text sequence. It gives +//! consumers the child's exit code, the count of unique denials, and a +//! flag indicating whether the decoder had to truncate the set (so a UX +//! can tell the user "showing N of many"). + +use serde::{Deserialize, Serialize}; + +/// The final frame in a captureDenials output stream. +/// +/// `total_denials` is the number of *unique* `(path, accessType)` pairs, +/// which matches the number of `denial` frames that preceded this one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DenialSummary { + /// Exit code of the sandboxed child process. + pub exit_code: i32, + + /// Count of unique `(path, accessType)` denials emitted (equals the + /// number of `denial` frames preceding this summary). + pub total_denials: usize, + + /// `true` when the decoder capped the emitted set and additional + /// denials were observed but not written. Consumers should surface + /// this so the user knows the list is partial. + pub denied_resources_truncated: bool, +} + +impl DenialSummary { + /// Builds a summary for a completed capture. + #[must_use] + pub fn new(exit_code: i32, total_denials: usize, denied_resources_truncated: bool) -> Self { + Self { + exit_code, + total_denials, + denied_resources_truncated, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_serialises_camel_case() { + let s = DenialSummary::new(0, 3, false); + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("\"exitCode\":0"), "got {json}"); + assert!(json.contains("\"totalDenials\":3"), "got {json}"); + assert!( + json.contains("\"deniedResourcesTruncated\":false"), + "got {json}" + ); + } + + #[test] + fn summary_round_trips_through_json() { + let s = DenialSummary::new(255, 42, true); + let json = serde_json::to_string(&s).unwrap(); + let parsed: DenialSummary = serde_json::from_str(&json).unwrap(); + assert_eq!(s, parsed); + } +}