From ad8c4306aae8efe43667d59abf2c4e8de1282014 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 21 Jul 2026 14:22:49 -0700 Subject: [PATCH 01/12] learning_mode_core: cross-platform denial model + NDJSON emitter Introduce the learning_mode_core crate as the cross-platform hinge of the captureDenials pipeline (PR4/PR5 foundation): - model: DeniedResource / ResourceType (file|ui|network|capability|other) / AccessType, camelCase wire contract with round-trip + key-stability tests. - summary: DenialSummary terminator (exitCode, totalDenials, deniedResourcesTruncated). - frame: self-describing type-tagged DenialFrame (denial|summary). - emit: RFC 7464 (0x1E-framed) NDJSON stream writer over any io::Write. - analyze: DenialAnalyzer decode trait + AnalyzeError, implemented per-OS by the backend ETL decoders (Windows first). No OS-specific code; 16 unit tests + 1 doc-test, clippy/fmt clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Cargo.lock | 9 + src/Cargo.toml | 2 + src/core/learning_mode_core/Cargo.toml | 10 ++ src/core/learning_mode_core/src/analyze.rs | 99 +++++++++++ src/core/learning_mode_core/src/emit.rs | 145 +++++++++++++++ src/core/learning_mode_core/src/frame.rs | 98 ++++++++++ src/core/learning_mode_core/src/lib.rs | 46 +++++ src/core/learning_mode_core/src/model.rs | 197 +++++++++++++++++++++ src/core/learning_mode_core/src/summary.rs | 69 ++++++++ 9 files changed, 675 insertions(+) create mode 100644 src/core/learning_mode_core/Cargo.toml create mode 100644 src/core/learning_mode_core/src/analyze.rs create mode 100644 src/core/learning_mode_core/src/emit.rs create mode 100644 src/core/learning_mode_core/src/frame.rs create mode 100644 src/core/learning_mode_core/src/lib.rs create mode 100644 src/core/learning_mode_core/src/model.rs create mode 100644 src/core/learning_mode_core/src/summary.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 82b872c1..9f34e003 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1265,6 +1265,15 @@ 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" 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/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..5b4fafcd --- /dev/null +++ b/src/core/learning_mode_core/src/analyze.rs @@ -0,0 +1,99 @@ +// 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; + +/// 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 the unique `(path, accessType)` observations found +/// in `source_path`; the caller wraps them with a +/// [`crate::summary::DenialSummary`] and emits an NDJSON stream via +/// [`crate::emit`]. +pub trait DenialAnalyzer { + /// Analyses the capture at `source_path`, returning the denials it + /// contains. + /// + /// # 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, AnalyzeError>; +} + +#[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, AnalyzeError> { + Ok(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); + } + + #[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..2398836e --- /dev/null +++ b/src/core/learning_mode_core/src/emit.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Framed NDJSON 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..4969d0eb --- /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 NDJSON +/// 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..02881562 --- /dev/null +++ b/src/core/learning_mode_core/src/lib.rs @@ -0,0 +1,46 @@ +// 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 NDJSON output file 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 NDJSON [`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 and UI denials are recorded under both `learningMode` +//! (block-and-log) and `permissiveLearningMode` (allow-and-log), but +//! **capability** ([`ResourceType::Capability`]) denials are currently +//! only recorded under permissive learning mode. Consumers must not +//! assume capability records are present under plain `learningMode`. + +#![deny(missing_docs)] + +pub mod analyze; +pub mod emit; +pub mod frame; +pub mod model; +pub mod summary; + +pub use analyze::{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..5cc2a60b --- /dev/null +++ b/src/core/learning_mode_core/src/model.rs @@ -0,0 +1,197 @@ +// 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 NDJSON output file (see [`crate::emit`]) is just 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}; + +/// 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. Only produced under permissive learning + /// mode today — see the mode caveat in the crate docs. + 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. + 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 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..96629533 --- /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 NDJSON output file. 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); + } +} From 1c4b34252744264e297bb0c0d9991c9cbbc67503 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 21 Jul 2026 15:01:03 -0700 Subject: [PATCH 02/12] Add Windows sealed-ETL denial decoder (PR4 pr4-etl-parse) Decode the learning-mode `.etl` that CaptureSession::finish seals into cross-platform learning_mode_core::DeniedResource values, implementing the DenialAnalyzer trait. - path_norm: kernel-form -> drive-letter, incl. the `\??\C:\...` / `\??\UNC\...` DOS-devices prefix the trace actually emits. - tdh_decode: TDH EVENT_RECORD -> (event_id, props). - extractors: route event 14/4907 (access check) by ObjectType (File -> file, Key -> registry/other, empty -> capability) and derive accessType from AccessMask with a File/Registry right vocabulary (Write > Execute > Read); event 27 -> Ui; event 28 -> capability denial (Denied gate, payload ProcessId). Missing/unparseable mask degrades to Unknown rather than dropping the denial. - etl_decode: file-mode OpenTraceW/ProcessTrace loop + dedup by (user-visible path, accessType). - examples/lm_analyze: NDJSON emit + `--raw` event dump for validation. Event vocabulary and AccessMask classification confirmed on the GE_CURRENT x64 VM against real block-and-log (Mode="Normal") and allow-and-log (Mode="Permissive") captures: File/registry denials decode identically in both modes; capability denials arrive as empty-ObjectType event 14 (permissive) or event 28 (block). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/backends/learning_mode/windows/Cargo.toml | 1 + .../windows/examples/lm_analyze.rs | 101 +++ .../learning_mode/windows/src/etl_decode.rs | 241 ++++++++ .../learning_mode/windows/src/extractors.rs | 583 ++++++++++++++++++ src/backends/learning_mode/windows/src/lib.rs | 11 + .../learning_mode/windows/src/path_norm.rs | 159 +++++ .../learning_mode/windows/src/tdh_decode.rs | 307 +++++++++ 7 files changed, 1403 insertions(+) create mode 100644 src/backends/learning_mode/windows/examples/lm_analyze.rs create mode 100644 src/backends/learning_mode/windows/src/etl_decode.rs create mode 100644 src/backends/learning_mode/windows/src/extractors.rs create mode 100644 src/backends/learning_mode/windows/src/path_norm.rs create mode 100644 src/backends/learning_mode/windows/src/tdh_decode.rs 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..38f8784c --- /dev/null +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Decode a sealed learning-mode `.etl` into the captureDenials NDJSON +//! output stream, or dump its raw ETW events for schema discovery. +//! +//! Usage: +//! +//! ```text +//! # Emit the DeniedResource NDJSON stream (0x1E-framed) to stdout: +//! cargo run -p learning_mode_windows --example lm_analyze -- +//! +//! # 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::{decode_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]"); + return 2; + }; + let raw = args.iter().any(|a| a == "--raw"); + let path = Path::new(etl_path); + + if raw { + dump_raw(path) + } else { + emit_ndjson(path) + } + } + + /// Decodes denials and writes the 0x1E-framed NDJSON stream to stdout. + fn emit_ndjson(path: &Path) -> i32 { + let denials = match EtlDenialAnalyzer.analyze(path) { + Ok(d) => d, + Err(e) => { + eprintln!("analyze failed: {e}"); + return 1; + } + }; + let summary = DenialSummary::new(0, denials.len(), false); + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + if let Err(e) = emit::write_stream(&mut handle, &denials, &summary) { + eprintln!("write failed: {e}"); + return 1; + } + eprintln!("lm_analyze: {} unique denial(s)", denials.len()); + 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 events = match decode_raw_events(path) { + Ok(e) => e, + Err(e) => { + eprintln!("decode failed: {e}"); + return 1; + } + }; + + let mut histogram: BTreeMap = BTreeMap::new(); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for ev in &events { + *histogram.entry(ev.event_id).or_default() += 1; + let props: Vec = ev.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); + let _ = writeln!(out, "event {} | {}", ev.event_id, props.join(" | ")); + } + let _ = writeln!(out, "--- {} event(s) total ---", events.len()); + for (id, count) in &histogram { + let _ = writeln!(out, " id {id}: {count}"); + } + 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..d81aca58 --- /dev/null +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -0,0 +1,241 @@ +// 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, collect the decoded events, then extract and +//! de-duplicate denials. +//! +//! [`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. + +use std::collections::HashSet; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; + +use learning_mode_core::{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, DecodedEventParts, RawDenial}; +use crate::{path_norm, tdh_decode}; + +/// `OpenTraceW` returns this sentinel (`(TRACEHANDLE)-1`) on failure. +const INVALID_PROCESSTRACE_HANDLE: u64 = u64::MAX; + +/// One decoded ETW event, retaining the header context the extractors need. +struct CollectedEvent { + pid: u32, + filetime: u64, + parts: DecodedEventParts, +} + +/// Accumulates decoded events during a `ProcessTrace` pass. A pointer to +/// this is handed to ETW via `EVENT_TRACE_LOGFILEW.Context` and read back +/// in the record callback. +#[derive(Default)] +struct Accumulator { + events: Vec, +} + +/// 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, AnalyzeError> { + let events = process_trace_file(source_path)?; + Ok(dedup_to_resources(events.iter().filter_map(|e| { + extract_denial(&e.parts, e.pid, e.filetime) + }))) + } +} + +/// Decodes every event in the ETL into `(event_id, props)` pairs, for +/// schema discovery / diagnostics. Preserves the on-disk order. +/// +/// # Errors +/// +/// Returns [`AnalyzeError`] if the trace cannot be opened or processed. +pub fn decode_raw_events(source_path: &Path) -> Result, AnalyzeError> { + Ok(process_trace_file(source_path)? + .into_iter() + .map(|e| e.parts) + .collect()) +} + +/// De-duplicates raw denials by `(user-visible path, accessType)`, +/// normalising kernel paths to drive-letter form and preserving first-seen +/// order. +fn dedup_to_resources>(raws: I) -> Vec { + let mut seen: HashSet<(String, learning_mode_core::AccessType)> = HashSet::new(); + let mut out = Vec::new(); + for raw in raws { + let path = + path_norm::to_user_visible(&raw.object_name).unwrap_or_else(|| raw.object_name.clone()); + if seen.insert((path.clone(), raw.access_type)) { + out.push(DeniedResource { + path, + resource_type: raw.resource_type, + access_type: raw.access_type, + pid: raw.pid, + filetime: raw.filetime, + }); + } + } + out +} + +/// Opens `source_path` as an ETL log file, runs `ProcessTrace` to +/// completion, and returns the decoded events. +fn process_trace_file(source_path: &Path) -> 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 accumulator = Accumulator::default(); + + 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::addr_of_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); + } + + // 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(accumulator.events) +} + +/// 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 let Some(parts) = unsafe { tdh_decode::decode_event_parts(event_record) } { + acc.events.push(CollectedEvent { + pid: header.ProcessId, + filetime: header.TimeStamp as u64, + parts, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use learning_mode_core::{AccessType, ResourceType}; + + 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); + 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); + assert_eq!(out[0].path, r"C:\z"); + assert_eq!(out[1].path, r"C:\a"); + } + + #[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:?}"); + } +} 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..39417c27 --- /dev/null +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -0,0 +1,583 @@ +// 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`]. +//! +//! ## 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-and-log` → `Mode="Normal"`, `allow-and-log` → +//! `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-and-log`; `allow-and-log` folds the same information into the +//! empty-`ObjectType` event 14 above. Mapped to [`ResourceType::Capability`]. +//! The capability *name* is carried in the `PackageSid` blob and is not +//! yet decoded, so the resource path is left empty for now (see the crate +//! mode caveat). + +use learning_mode_core::{AccessType, ResourceType}; + +/// 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 { + /// 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 { + 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, + } +} + +/// 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()) + .unwrap_or_default(); + + 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; the resource identifier is taken +/// from the first UI-ish field present. The event carries no usable access +/// mask, so the access type stays [`AccessType::Unknown`]. +pub fn build_denial_from_learning_mode( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Option { + let object_name = find_prop(&parts.props, "ObjectName") + .or_else(|| find_prop(&parts.props, "ResourceName")) + .or_else(|| find_prop(&parts.props, "ProcessName")) + .map(|v| v.trim_matches('"').to_string()) + .unwrap_or_default(); + + 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-and-log`. 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. The capability name +/// is carried in the `PackageSid` blob and is not yet decoded, so the +/// resource name is left empty for now. +pub fn build_denial_from_capability( + parts: &DecodedEventParts, + pid: u32, + filetime: u64, +) -> Option { + // If the record explicitly reports the check as not-denied, skip it; + // absence of the field is treated as a denial (fail open on decode gap). + if let Some(denied) = find_prop(&parts.props, "Denied") { + if !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); + + // Prefer a decoded capability name if a future decoder surfaces one; + // otherwise leave the name empty until the PackageSid blob is decoded. + let object_name = find_prop(&parts.props, "CapabilityName") + .or_else(|| find_prop(&parts.props, "Capability")) + .map(|v| v.trim_matches('"').to_string()) + .unwrap_or_default(); + + Some(RawDenial { + pid, + resource_type: ResourceType::Capability, + object_name, + access_type: AccessType::Unknown, + filetime, + event_id: parts.event_id, + }) +} + +/// 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, + 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). + GENERIC_EXECUTE, + ) + } 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_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_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 { + DecodedEventParts { + 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_object_type_is_capability() { + // 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"), + ], + ); + let ev = extract_denial(&p, 5900, FIXED_FILETIME).expect("should extract"); + assert_eq!(ev.resource_type, ResourceType::Capability); + assert_eq!(ev.access_type, AccessType::Unknown); + assert_eq!(ev.object_name, ""); + } + + #[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, &[("ObjectName", "\"Clipboard\"")]); + 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, "Clipboard"); + } + + // ---- event 28 capability denial --------------------------------------- + + #[test] + fn capability_denial_event_28_extracted() { + // Real block-and-log shape: image name + hex ProcessId + Denied. + let p = parts( + 28, + &[ + ("ProcessName", "\"conhost.exe\""), + ("ProcessId", "0x1acc"), + ("Category", "1"), + ("Denied", "true"), + ], + ); + 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, ""); + } + + #[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_falls_back_to_header_pid() { + let p = parts(28, &[("Denied", "true")]); + 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(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!) + // 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..701dca44 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -35,6 +35,17 @@ 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::{decode_raw_events, EtlDenialAnalyzer}; #[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..7e14b105 --- /dev/null +++ b/src/backends/learning_mode/windows/src/path_norm.rs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Kernel-form -> user-visible path normalization. +//! +//! ETW emits filesystem paths in two kernel forms: +//! +//! - the NT object-manager DOS-devices form `\??\C:\Users\foo\file.txt` +//! (and `\??\UNC\server\share` for network paths), 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 `\??\` form is a pure textual prefix strip. For the `\Device\` form +//! we walk `A:` through `Z:` calling `QueryDosDeviceW` to discover each +//! drive's kernel mount (`\Device\HarddiskVolumeN`), 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). +//! +//! Non-file paths (registry `\REGISTRY\Machine\...`, MUP / network shares, +//! etc.) are returned unchanged. + +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 +/// object-manager DOS-devices prefix (`\??\C:\...`, `\??\UNC\...`) or a +/// known `\Device\HarddiskVolumeN\...` prefix. Returns `None` when the path +/// is not a filesystem path that can be canonicalized (registry, MUP, +/// unknown device). Callers should fall back to the original input on +/// `None`. +pub fn to_user_visible(kernel_path: &str) -> Option { + // NT object-manager DOS-devices prefix: `\??\C:\...` is exactly + // `C:\...`, and `\??\UNC\server\share` is `\\server\share`. This is a + // pure textual mapping that needs no device lookup. + if let Some(rest) = kernel_path.strip_prefix(r"\??\") { + if let Some(unc) = rest.strip_prefix(r"UNC\") { + return Some(format!(r"\\{unc}")); + } + return Some(rest.to_string()); + } + + if !kernel_path.starts_with(r"\Device\") { + return None; + } + + let map = DRIVE_MAP.get_or_init(load_drive_map); + + for (letter, prefix) in map { + if let Some(rest) = kernel_path.strip_prefix(prefix.as_str()) { + return Some(format!("{letter}{rest}")); + } + } + None +} + +/// 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]); + + // Common shapes: `\Device\HarddiskVolume3`, `\Device\Mup`, etc. + // Only canonicalize filesystem volumes; skip non-Harddisk entries. + if device.starts_with(r"\Device\HarddiskVolume") { + 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 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 HarddiskVolume entry" + ); + } + + #[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") + ); + } + } +} 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..d56557b1 --- /dev/null +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -0,0 +1,307 @@ +// 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_HEXINT32: u16 = 20; +const TDH_INTYPE_HEXINT64: u16 = 21; + +/// 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) -> Option { + 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 None; + } + + let mut buffer = vec![0u8; buf_size as usize]; + let info_ptr = buffer.as_mut_ptr().cast::(); + let status = + unsafe { TdhGetEventInformation(event_record, None, Some(info_ptr), &mut buf_size) }; + if status != 0 { + return None; + } + + let info = unsafe { &*info_ptr }; + + let event_id = unsafe { (*event_record).EventHeader.EventDescriptor.Id }; + let props = decode_properties(&buffer, info, event_record); + + Some(DecodedEventParts { event_id, props }) +} + +fn decode_properties( + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + event_record: *mut EVENT_RECORD, +) -> Vec<(String, 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 Vec::new(); + } + + let prop_count = info.TopLevelPropertyCount as usize; + let mut results = Vec::with_capacity(prop_count); + let mut offset: usize = 0; + + for i in 0..prop_count { + // SAFETY: EventPropertyInfoArray is a flexible-length array of + // EVENT_PROPERTY_INFO; TopLevelPropertyCount bounds the index. + let prop_info = unsafe { + let base = + std::ptr::addr_of!(info.EventPropertyInfoArray) as *const EVENT_PROPERTY_INFO; + &*base.add(i) + }; + + let prop_name = + wide_str_at(info_buf, prop_info.NameOffset).unwrap_or_else(|| format!("prop{i}")); + + // PROPERTY_FLAGS::PropertyStruct = 1 — skip structured nested + // properties (not needed for the learning-mode denial events). + if prop_info.Flags.0 & 1 != 0 { + results.push((prop_name, "".to_string())); + continue; + } + + // SAFETY: nonStructType is valid when PropertyStruct flag is unset. + let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; + // SAFETY: Anonymous3.length is always valid (a u16). + let declared_length = unsafe { prop_info.Anonymous3.length } as usize; + + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + // SAFETY: user_data is valid for user_data_len bytes; offset <= user_data_len. + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + + let (value_str, consumed) = + format_property_value(in_type, declared_length, data_ptr, remaining); + + offset += consumed; + results.push((prop_name, value_str)); + } + + results +} + +fn format_property_value( + in_type: u16, + declared_length: usize, + data: *const u8, + available: usize, +) -> (String, usize) { + if data.is_null() || available == 0 { + return ("".to_string(), 0); + } + + match in_type { + TDH_INTYPE_UNICODESTRING => { + let max_wchars = available / 2; + // SAFETY: data is valid for `available` bytes; max_wchars + // bounds the slice. + let wchars = unsafe { std::slice::from_raw_parts(data.cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + let s = String::from_utf16_lossy(&wchars[..len]); + // Include the null terminator in consumed bytes when present. + let consumed = ((len + 1).min(max_wchars)) * 2; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_ANSISTRING => { + // SAFETY: data is valid for `available` bytes. + let bytes = unsafe { std::slice::from_raw_parts(data, available) }; + let len = bytes.iter().position(|&b| b == 0).unwrap_or(available); + let s = String::from_utf8_lossy(&bytes[..len]); + let consumed = (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 available >= 8 => ( + // 64-bit pointer; on 32-bit hosts this would be 4 bytes but + // we only target x64. Unaligned read because ETW payload + // alignment is not guaranteed. + format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), + 8, + ), + // 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)), + } +} + +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::*; + + #[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_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 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); + } +} From 3d6cd5449e1f1a0da72df52e17d5c31901060202 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 21 Jul 2026 15:07:10 -0700 Subject: [PATCH 03/12] Add decode-composition tests over real event shapes (pr4-analyze-tests) Factor the pure decode composition (extract_denial routing -> path normalisation -> dedup) out of EtlDenialAnalyzer::analyze into resources_from_events, and cover it with tests built from the event shapes captured on hardware for both learning modes: - block-and-log (Mode="Normal"): file/registry event 14 + capability event 28 - allow-and-log (Mode="Permissive"): capability folded into empty-ObjectType event 14 (no event 28) - empty-path capabilities from event 14 and event 28 collapse to one resource - non-actionable object types / not-denied records / unknown event IDs dropped A live ETW/TDH fixture read is intentionally not used: it needs the provider manifests registered on the machine, and a raw capture embeds token SIDs that don't belong in a public repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../learning_mode/windows/src/etl_decode.rs | 177 +++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index d81aca58..554cc1b9 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -54,12 +54,24 @@ pub struct EtlDenialAnalyzer; impl DenialAnalyzer for EtlDenialAnalyzer { fn analyze(&self, source_path: &Path) -> Result, AnalyzeError> { let events = process_trace_file(source_path)?; - Ok(dedup_to_resources(events.iter().filter_map(|e| { - extract_denial(&e.parts, e.pid, e.filetime) - }))) + Ok(resources_from_events(&events)) } } +/// 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). +fn resources_from_events(events: &[CollectedEvent]) -> Vec { + dedup_to_resources( + events + .iter() + .filter_map(|e| extract_denial(&e.parts, e.pid, e.filetime)), + ) +} + /// Decodes every event in the ETL into `(event_id, props)` pairs, for /// schema discovery / diagnostics. Preserves the on-disk order. /// @@ -238,4 +250,163 @@ mod tests { .unwrap_err(); assert!(matches!(err, AnalyzeError::Open { .. }), "got {err:?}"); } + + // ---- 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 { + event_id, + props: kv + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + }, + } + } + + /// Mirrors the real `Mode="Normal"` (block-and-log) capture: file/registry + /// access checks as event 14 plus a compact capability denial as event 28. + #[test] + fn block_and_log_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) -> empty path, unknown access. + event( + 28, + 0, + 12, + &[ + ("ProcessName", "\"conhost.exe\""), + ("ProcessId", "0x1acc"), + ("Denied", "true"), + ], + ), + ]; + + let out = resources_from_events(&events); + 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, ""); + 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-and-log) 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_and_log_shape_folds_capability_into_event_14() { + 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); + assert_eq!(out.len(), 2); + assert_eq!(out[0].path, r"C:\data\test\bin\"); + assert_eq!(out[0].access_type, AccessType::Write); + assert_eq!(out[1].resource_type, ResourceType::Capability); + assert_eq!(out[1].path, ""); + assert_eq!(out[1].access_type, AccessType::Unknown); + } + + /// Both a permissive empty-`ObjectType` event 14 and a block-mode event 28 + /// capability denial reduce to the same `("", Unknown)` key, so they + /// collapse to a single capability resource. + #[test] + fn empty_path_capabilities_collapse_to_one() { + 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")]), + ]; + let out = resources_from_events(&events); + assert_eq!(out.len(), 1); + assert_eq!(out[0].resource_type, ResourceType::Capability); + assert_eq!(out[0].path, ""); + } + + /// 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).is_empty()); + } } From 52b96914fd04f7232093128b9a14b7752cf77dd5 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 28 Jul 2026 16:10:21 -0700 Subject: [PATCH 04/12] Update decoder terminology for capture modes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- src/backends/learning_mode/windows/src/etl_decode.rs | 4 ++-- src/backends/learning_mode/windows/src/extractors.rs | 8 ++++---- src/core/learning_mode_core/src/lib.rs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index 554cc1b9..1f3702c4 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -273,7 +273,7 @@ mod tests { } } - /// Mirrors the real `Mode="Normal"` (block-and-log) capture: file/registry + /// 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_and_log_shape_decodes_and_classifies() { @@ -333,7 +333,7 @@ mod tests { assert_eq!(out[2].pid, 0x1acc, "pid from payload ProcessId"); } - /// Mirrors the real `Mode="Permissive"` (allow-and-log) capture: the same + /// 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] diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 39417c27..d765834f 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -24,14 +24,14 @@ //! 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-and-log` → `Mode="Normal"`, `allow-and-log` → +//! 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-and-log`; `allow-and-log` folds the same information into the +//! `block`; `allow` folds the same information into the //! empty-`ObjectType` event 14 above. Mapped to [`ResourceType::Capability`]. //! The capability *name* is carried in the `PackageSid` blob and is not //! yet decoded, so the resource path is left empty for now (see the crate @@ -175,7 +175,7 @@ pub fn build_denial_from_learning_mode( /// Builds a [`RawDenial`] from a capability-denial (event 28) payload. /// -/// Emitted under `block-and-log`. The record reports a `Denied` boolean; we +/// 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. The capability name @@ -465,7 +465,7 @@ mod tests { #[test] fn capability_denial_event_28_extracted() { - // Real block-and-log shape: image name + hex ProcessId + Denied. + // Real block shape: image name + hex ProcessId + Denied. let p = parts( 28, &[ diff --git a/src/core/learning_mode_core/src/lib.rs b/src/core/learning_mode_core/src/lib.rs index 02881562..e1e13688 100644 --- a/src/core/learning_mode_core/src/lib.rs +++ b/src/core/learning_mode_core/src/lib.rs @@ -26,7 +26,7 @@ //! //! What a capture contains depends on the active OS learning mode. //! File/path and UI denials are recorded under both `learningMode` -//! (block-and-log) and `permissiveLearningMode` (allow-and-log), but +//! (`block`) and `permissiveLearningMode` (`allow`), but //! **capability** ([`ResourceType::Capability`]) denials are currently //! only recorded under permissive learning mode. Consumers must not //! assume capability records are present under plain `learningMode`. From 9f9df3146d0d49dc0c51363f7af7f544bc79dd22 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 28 Jul 2026 16:10:52 -0700 Subject: [PATCH 05/12] Update PR4 workspace lockfile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- src/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cargo.lock b/src/Cargo.lock index 9f34e003..b8c59732 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1279,6 +1279,7 @@ name = "learning_mode_windows" version = "0.7.0" dependencies = [ "flatbuffers", + "learning_mode_core", "sandbox_spec", "thiserror", "windows", From 2ec77aa6a38beefd674f719bb87629e5b853536d Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 10:42:34 -0700 Subject: [PATCH 06/12] Harden Learning Mode ETL analysis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../windows/examples/lm_analyze.rs | 37 ++- .../learning_mode/windows/src/etl_decode.rs | 239 ++++++++++---- .../learning_mode/windows/src/extractors.rs | 140 +++++++-- .../learning_mode/windows/src/path_norm.rs | 16 +- .../learning_mode/windows/src/tdh_decode.rs | 297 +++++++++++++++--- src/core/learning_mode_core/src/analyze.rs | 38 ++- src/core/learning_mode_core/src/lib.rs | 11 +- src/core/learning_mode_core/src/model.rs | 4 +- 8 files changed, 629 insertions(+), 153 deletions(-) diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index 38f8784c..bfe01762 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -8,7 +8,7 @@ //! //! ```text //! # Emit the DeniedResource NDJSON stream (0x1E-framed) to stdout: -//! cargo run -p learning_mode_windows --example lm_analyze -- +//! 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 @@ -40,7 +40,7 @@ mod windows_impl { 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]"); + eprintln!("usage: lm_analyze [--raw | --exit-code ]"); return 2; }; let raw = args.iter().any(|a| a == "--raw"); @@ -49,27 +49,48 @@ mod windows_impl { if raw { dump_raw(path) } else { - emit_ndjson(path) + let Some(exit_code) = parse_exit_code(&args) else { + eprintln!("--exit-code is required unless --raw is used"); + return 2; + }; + emit_ndjson(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 0x1E-framed NDJSON stream to stdout. - fn emit_ndjson(path: &Path) -> i32 { - let denials = match EtlDenialAnalyzer.analyze(path) { + fn emit_ndjson(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(0, denials.len(), false); + 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, &denials, &summary) { + if let Err(e) = emit::write_stream(&mut handle, &analysis.denials, &summary) { eprintln!("write failed: {e}"); return 1; } - eprintln!("lm_analyze: {} unique denial(s)", denials.len()); + eprintln!( + "lm_analyze: {} unique denial(s){}", + analysis.denials.len(), + if analysis.denied_resources_truncated { + " (truncated)" + } else { + "" + } + ); 0 } diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index 1f3702c4..a6a23bad 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -8,8 +8,8 @@ //! 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, collect the decoded events, then extract and -//! de-duplicate denials. +//! 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 @@ -19,32 +19,99 @@ use std::collections::HashSet; use std::os::windows::ffi::OsStrExt; use std::path::Path; -use learning_mode_core::{AnalyzeError, DenialAnalyzer, DeniedResource}; +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, DecodedEventParts, RawDenial}; +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, } -/// Accumulates decoded events during a `ProcessTrace` pass. A pointer to -/// this is handed to ETW via `EVENT_TRACE_LOGFILEW.Context` and read back -/// in the record callback. -#[derive(Default)] +#[derive(Clone, Copy)] +enum CollectionMode { + Analyze, + Raw, +} + +/// Accumulates bounded analysis results or raw diagnostic events during a +/// `ProcessTrace` pass. struct Accumulator { - events: Vec, + mode: CollectionMode, + denials: Vec, + seen: HashSet<(String, learning_mode_core::AccessType)>, + truncated: bool, + raw_events: Vec, + decode_error: Option, +} + +impl Accumulator { + fn analyze() -> Self { + Self { + mode: CollectionMode::Analyze, + denials: Vec::new(), + seen: HashSet::new(), + truncated: false, + raw_events: Vec::new(), + decode_error: None, + } + } + + fn raw() -> Self { + Self { + mode: CollectionMode::Raw, + ..Self::analyze() + } + } + + fn add_raw_denial(&mut self, raw: RawDenial) { + let path = + 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 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. @@ -52,9 +119,8 @@ struct Accumulator { pub struct EtlDenialAnalyzer; impl DenialAnalyzer for EtlDenialAnalyzer { - fn analyze(&self, source_path: &Path) -> Result, AnalyzeError> { - let events = process_trace_file(source_path)?; - Ok(resources_from_events(&events)) + fn analyze(&self, source_path: &Path) -> Result { + process_trace_file(source_path, CollectionMode::Analyze)?.into_analysis() } } @@ -64,7 +130,8 @@ impl DenialAnalyzer for EtlDenialAnalyzer { /// [`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). -fn resources_from_events(events: &[CollectedEvent]) -> Vec { +#[cfg(test)] +fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { dedup_to_resources( events .iter() @@ -79,37 +146,33 @@ fn resources_from_events(events: &[CollectedEvent]) -> Vec { /// /// Returns [`AnalyzeError`] if the trace cannot be opened or processed. pub fn decode_raw_events(source_path: &Path) -> Result, AnalyzeError> { - Ok(process_trace_file(source_path)? - .into_iter() - .map(|e| e.parts) - .collect()) + let accumulator = process_trace_file(source_path, CollectionMode::Raw)?; + if let Some(error) = accumulator.decode_error { + return Err(AnalyzeError::Decode(error)); + } + Ok(accumulator.raw_events) } /// De-duplicates raw denials by `(user-visible path, accessType)`, /// normalising kernel paths to drive-letter form and preserving first-seen /// order. -fn dedup_to_resources>(raws: I) -> Vec { - let mut seen: HashSet<(String, learning_mode_core::AccessType)> = HashSet::new(); - let mut out = Vec::new(); +#[cfg(test)] +fn dedup_to_resources>(raws: I) -> AnalysisResult { + let mut accumulator = Accumulator::analyze(); for raw in raws { - let path = - path_norm::to_user_visible(&raw.object_name).unwrap_or_else(|| raw.object_name.clone()); - if seen.insert((path.clone(), raw.access_type)) { - out.push(DeniedResource { - path, - resource_type: raw.resource_type, - access_type: raw.access_type, - pid: raw.pid, - filetime: raw.filetime, - }); - } + accumulator.add_raw_denial(raw); } - out + 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) -> Result, AnalyzeError> { +fn process_trace_file( + source_path: &Path, + mode: CollectionMode, +) -> Result { // 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 { @@ -123,7 +186,10 @@ fn process_trace_file(source_path: &Path) -> Result, Analyze .chain(std::iter::once(0)) .collect(); - let mut accumulator = Accumulator::default(); + let mut accumulator = match mode { + CollectionMode::Analyze => Accumulator::analyze(), + CollectionMode::Raw => Accumulator::raw(), + }; let mut logfile: EVENT_TRACE_LOGFILEW = unsafe { core::mem::zeroed() }; logfile.LogFileName = PWSTR(name_wide.as_mut_ptr()); @@ -164,7 +230,7 @@ fn process_trace_file(source_path: &Path) -> Result, Analyze ))); } - Ok(accumulator.events) + Ok(accumulator) } /// ETW record callback, invoked by `ProcessTrace` for every event in the @@ -190,12 +256,28 @@ unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) // aliasing/concurrency with the owner occurs. let acc = unsafe { &mut *context }; - if let Some(parts) = unsafe { tdh_decode::decode_event_parts(event_record) } { - acc.events.push(CollectedEvent { - pid: header.ProcessId, - filetime: header.TimeStamp as u64, - parts, - }); + 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.raw_events.push(parts), + }, + Err(error) => { + if acc.decode_error.is_none() { + acc.decode_error = + Some(format!("provider {:?} event {event_id}: {error}", provider)); + } + } } } @@ -223,7 +305,7 @@ mod tests { raw(r"C:\a", AccessType::Write, ResourceType::File), raw(r"C:\b", AccessType::Read, ResourceType::File), ]; - let out = dedup_to_resources(denials); + 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); @@ -237,11 +319,36 @@ mod tests { raw(r"C:\z", AccessType::Read, ResourceType::File), raw(r"C:\a", AccessType::Read, ResourceType::File), ]; - let out = dedup_to_resources(denials); + 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 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; @@ -251,6 +358,17 @@ mod tests { 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: @@ -264,6 +382,11 @@ mod tests { 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() @@ -276,7 +399,7 @@ mod tests { /// 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_and_log_shape_decodes_and_classifies() { + fn block_shape_decodes_and_classifies() { let events = vec![ // File write (DELETE | FILE_READ_DATA -> Write), \??\ prefix. event( @@ -302,7 +425,7 @@ mod tests { ("AccessMask", "0x20019"), ], ), - // Capability denial (event 28) -> empty path, unknown access. + // Capability denial (event 28) with a decoded identifier. event( 28, 0, @@ -311,11 +434,12 @@ mod tests { ("ProcessName", "\"conhost.exe\""), ("ProcessId", "0x1acc"), ("Denied", "true"), + ("PackageSid", "S-1-15-3-1"), ], ), ]; - let out = resources_from_events(&events); + let out = resources_from_events(&events).denials; assert_eq!(out.len(), 3); assert_eq!(out[0].path, r"C:\data\test\bin\"); @@ -327,7 +451,7 @@ mod tests { assert_eq!(out[1].resource_type, ResourceType::Other); assert_eq!(out[1].access_type, AccessType::Read); - assert_eq!(out[2].path, ""); + 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"); @@ -337,7 +461,7 @@ mod tests { /// 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_and_log_shape_folds_capability_into_event_14() { + fn allow_shape_omits_unidentified_capability_event() { let events = vec![ event( 14, @@ -364,20 +488,14 @@ mod tests { ), ]; - let out = resources_from_events(&events); - assert_eq!(out.len(), 2); + 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); - assert_eq!(out[1].resource_type, ResourceType::Capability); - assert_eq!(out[1].path, ""); - assert_eq!(out[1].access_type, AccessType::Unknown); } - /// Both a permissive empty-`ObjectType` event 14 and a block-mode event 28 - /// capability denial reduce to the same `("", Unknown)` key, so they - /// collapse to a single capability resource. #[test] - fn empty_path_capabilities_collapse_to_one() { + fn unidentified_capability_events_are_omitted() { let events = vec![ event( 14, @@ -392,10 +510,7 @@ mod tests { event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "true")]), event(28, 0, 3, &[("ProcessId", "0x20"), ("Denied", "true")]), ]; - let out = resources_from_events(&events); - assert_eq!(out.len(), 1); - assert_eq!(out[0].resource_type, ResourceType::Capability); - assert_eq!(out[0].path, ""); + assert!(resources_from_events(&events).denials.is_empty()); } /// Non-actionable object types and not-denied capability records are @@ -407,6 +522,6 @@ mod tests { event(28, 0, 2, &[("ProcessId", "0x10"), ("Denied", "false")]), event(9999, 1, 3, &[("Foo", "\"bar\"")]), ]; - assert!(resources_from_events(&events).is_empty()); + 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 index d765834f..b611e3fe 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -33,11 +33,28 @@ //! record (`Denied` / `PackageSid` / `ProcessId`), emitted under //! `block`; `allow` folds the same information into the //! empty-`ObjectType` event 14 above. Mapped to [`ResourceType::Capability`]. -//! The capability *name* is carried in the `PackageSid` blob and is not -//! yet decoded, so the resource path is left empty for now (see the crate -//! mode caveat). +//! 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. /// @@ -46,6 +63,8 @@ use learning_mode_core::{AccessType, ResourceType}; /// 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 @@ -79,6 +98,9 @@ pub struct RawDenial { /// 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), @@ -87,6 +109,17 @@ pub fn extract_denial(parts: &DecodedEventParts, pid: u32, filetime: u64) -> Opt } } +/// 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` @@ -120,6 +153,9 @@ pub fn build_denial_from_access_check( let object_name = find_prop(&parts.props, "ObjectName") .map(|v| v.trim_matches('"').to_string()) .unwrap_or_default(); + if resource_type == ResourceType::Capability && object_name.is_empty() { + return None; + } let access_type = if resource_type == ResourceType::Capability { // Capability checks report a mask (often 0x1) that is not a @@ -149,19 +185,26 @@ pub fn build_denial_from_access_check( /// Builds a [`RawDenial`] from a `LearningModeViolation` (event 27) payload. /// -/// These represent UI-surface denials; the resource identifier is taken -/// from the first UI-ish field present. The event carries no usable access -/// mask, so the access type stays [`AccessType::Unknown`]. +/// 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 object_name = find_prop(&parts.props, "ObjectName") - .or_else(|| find_prop(&parts.props, "ResourceName")) - .or_else(|| find_prop(&parts.props, "ProcessName")) - .map(|v| v.trim_matches('"').to_string()) - .unwrap_or_default(); + 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, @@ -178,9 +221,8 @@ pub fn build_denial_from_learning_mode( /// 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. The capability name -/// is carried in the `PackageSid` blob and is not yet decoded, so the -/// resource name is left empty for now. +/// 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, @@ -198,12 +240,13 @@ pub fn build_denial_from_capability( .and_then(|v| parse_u32(v)) .unwrap_or(pid); - // Prefer a decoded capability name if a future decoder surfaces one; - // otherwise leave the name empty until the PackageSid blob is decoded. + // 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()) - .unwrap_or_default(); + .filter(|name| !name.is_empty() && name != "" && name != "")?; Some(RawDenial { pid, @@ -215,6 +258,22 @@ pub fn build_denial_from_capability( }) } +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 @@ -326,7 +385,21 @@ mod tests { 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() @@ -409,7 +482,7 @@ mod tests { } #[test] - fn access_check_empty_object_type_is_capability() { + 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( @@ -421,10 +494,7 @@ mod tests { ("AccessMask", "0x1"), ], ); - let ev = extract_denial(&p, 5900, FIXED_FILETIME).expect("should extract"); - assert_eq!(ev.resource_type, ResourceType::Capability); - assert_eq!(ev.access_type, AccessType::Unknown); - assert_eq!(ev.object_name, ""); + assert!(extract_denial(&p, 5900, FIXED_FILETIME).is_none()); } #[test] @@ -453,12 +523,19 @@ mod tests { #[test] fn learning_mode_violation_extracted_as_ui() { - let p = parts(27, &[("ObjectName", "\"Clipboard\"")]); + 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, "Clipboard"); + assert_eq!(ev.object_name, "WriteClipboard"); } // ---- event 28 capability denial --------------------------------------- @@ -473,6 +550,7 @@ mod tests { ("ProcessId", "0x1acc"), ("Category", "1"), ("Denied", "true"), + ("PackageSid", "S-1-15-3-1"), ], ); let ev = extract_denial(&p, 42, FIXED_FILETIME).expect("should extract"); @@ -480,7 +558,7 @@ mod tests { 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, ""); + assert_eq!(ev.object_name, "S-1-15-3-1"); } #[test] @@ -489,9 +567,19 @@ mod tests { 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")]); + 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); } diff --git a/src/backends/learning_mode/windows/src/path_norm.rs b/src/backends/learning_mode/windows/src/path_norm.rs index 7e14b105..30c620b3 100644 --- a/src/backends/learning_mode/windows/src/path_norm.rs +++ b/src/backends/learning_mode/windows/src/path_norm.rs @@ -58,7 +58,9 @@ pub fn to_user_visible(kernel_path: &str) -> Option { for (letter, prefix) in map { if let Some(rest) = kernel_path.strip_prefix(prefix.as_str()) { - return Some(format!("{letter}{rest}")); + if rest.is_empty() || rest.starts_with('\\') { + return Some(format!("{letter}{rest}")); + } } } None @@ -156,4 +158,16 @@ mod tests { ); } } + + #[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 index d56557b1..839287ba 100644 --- a/src/backends/learning_mode/windows/src/tdh_decode.rs +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -28,8 +28,13 @@ 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; /// Decodes an `EVENT_RECORD` into `DecodedEventParts`. /// @@ -40,12 +45,16 @@ const TDH_INTYPE_HEXINT64: u16 = 21; /// `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) -> Option { +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 None; + return Err(format!( + "TdhGetEventInformation(size) failed with Win32 error {status}" + )); } let mut buffer = vec![0u8; buf_size as usize]; @@ -53,22 +62,29 @@ pub unsafe fn decode_event_parts(event_record: *mut EVENT_RECORD) -> Option Vec<(String, String)> { +) -> Result, String> { // SAFETY: caller passes a valid EVENT_RECORD; the field accesses // are reads of POD fields. let event = unsafe { &*event_record }; @@ -76,53 +92,176 @@ fn decode_properties( let user_data_len = event.UserDataLength as usize; if user_data.is_null() || user_data_len == 0 { - return Vec::new(); + 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; for i in 0..prop_count { - // SAFETY: EventPropertyInfoArray is a flexible-length array of - // EVENT_PROPERTY_INFO; TopLevelPropertyCount bounds the index. - let prop_info = unsafe { - let base = - std::ptr::addr_of!(info.EventPropertyInfoArray) as *const EVENT_PROPERTY_INFO; - &*base.add(i) - }; + let value = decode_property( + i, + info_buf, + info, + user_data, + user_data_len, + &mut offset, + &mut numeric_values, + )?; + results.push(value); + } + + Ok(results) +} - let prop_name = - wide_str_at(info_buf, prop_info.NameOffset).unwrap_or_else(|| format!("prop{i}")); +fn decode_property( + index: usize, + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + user_data: *const u8, + user_data_len: usize, + offset: &mut usize, + numeric_values: &mut [Option], +) -> Result<(String, String), String> { + if index >= info.PropertyCount as usize { + return Err(format!("property index {index} is out of range")); + } + let prop_info = event_property_info(info, index); + let prop_name = + wide_str_at(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}" + )); + } - // PROPERTY_FLAGS::PropertyStruct = 1 — skip structured nested - // properties (not needed for the learning-mode denial events). - if prop_info.Flags.0 & 1 != 0 { - results.push((prop_name, "".to_string())); - continue; + 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; + for _ in 0..count { + for child_index in start_index..start_index + member_count { + let _ = decode_property( + child_index, + info_buf, + info, + user_data, + user_data_len, + offset, + numeric_values, + )?; + } } + return Ok((prop_name, "".to_string())); + } - // SAFETY: nonStructType is valid when PropertyStruct flag is unset. - let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; - // SAFETY: Anonymous3.length is always valid (a u16). - let declared_length = unsafe { prop_info.Anonymous3.length } as usize; + 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 remaining = user_data_len.saturating_sub(offset); + let mut values = Vec::with_capacity(count.min(available_element_bound( + in_type, + user_data_len.saturating_sub(*offset), + ))); + let mut numeric_value = None; + for _ in 0..count { + let remaining = 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 { - // SAFETY: user_data is valid for user_data_len bytes; offset <= user_data_len. - unsafe { user_data.add(offset) } + unsafe { user_data.add(*offset) } } else { std::ptr::null() }; - - let (value_str, consumed) = + let (value, consumed) = format_property_value(in_type, declared_length, data_ptr, remaining); + 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((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 available_element_bound(in_type: u16, available: 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_INT64 | TDH_INTYPE_UINT64 | TDH_INTYPE_POINTER | TDH_INTYPE_HEXINT64 => 8, + _ => 1, + }; + (available / element_size).max(1) +} - offset += consumed; - results.push((prop_name, value_str)); +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}" + ) + }) +} - results +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( @@ -137,22 +276,39 @@ fn format_property_value( match in_type { TDH_INTYPE_UNICODESTRING => { - let max_wchars = available / 2; + 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; max_wchars // bounds the slice. let wchars = unsafe { std::slice::from_raw_parts(data.cast::(), max_wchars) }; let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); let s = String::from_utf16_lossy(&wchars[..len]); // Include the null terminator in consumed bytes when present. - let consumed = ((len + 1).min(max_wchars)) * 2; + let consumed = if declared_length > 0 { + byte_len + } else { + ((len + 1).min(max_wchars)) * 2 + }; (format!("\"{s}\""), consumed) } TDH_INTYPE_ANSISTRING => { - // SAFETY: data is valid for `available` bytes. - let bytes = unsafe { std::slice::from_raw_parts(data, available) }; - let len = bytes.iter().position(|&b| b == 0).unwrap_or(available); + 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 = (len + 1).min(available); + let consumed = if declared_length > 0 { + byte_len + } else { + (len + 1).min(available) + }; (format!("\"{s}\""), consumed) } TDH_INTYPE_INT8 if available >= 1 => { @@ -208,6 +364,7 @@ fn format_property_value( 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. @@ -215,6 +372,34 @@ fn format_property_value( } } +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() { @@ -281,6 +466,15 @@ mod tests { assert_eq!(consumed, bytes.len()); // 5 chars + null = 12 bytes } + #[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(); @@ -304,4 +498,27 @@ mod tests { 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/src/analyze.rs b/src/core/learning_mode_core/src/analyze.rs index 5b4fafcd..314091cf 100644 --- a/src/core/learning_mode_core/src/analyze.rs +++ b/src/core/learning_mode_core/src/analyze.rs @@ -17,6 +17,27 @@ 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 { @@ -41,19 +62,19 @@ pub enum AnalyzeError { /// Decodes a platform-native capture source into de-duplicated denials. /// -/// Implementors return the unique `(path, accessType)` observations found -/// in `source_path`; the caller wraps them with a +/// 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 NDJSON stream via /// [`crate::emit`]. pub trait DenialAnalyzer { - /// Analyses the capture at `source_path`, returning the denials it - /// contains. + /// 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, AnalyzeError>; + fn analyze(&self, source_path: &Path) -> Result; } #[cfg(test)] @@ -66,8 +87,8 @@ mod tests { struct FakeAnalyzer(Vec); impl DenialAnalyzer for FakeAnalyzer { - fn analyze(&self, _source_path: &Path) -> Result, AnalyzeError> { - Ok(self.0.clone()) + fn analyze(&self, _source_path: &Path) -> Result { + Ok(AnalysisResult::complete(self.0.clone())) } } @@ -82,7 +103,8 @@ mod tests { }]; let analyzer: Box = Box::new(FakeAnalyzer(denials.clone())); let got = analyzer.analyze(Path::new("ignored.etl")).unwrap(); - assert_eq!(got, denials); + assert_eq!(got.denials, denials); + assert!(!got.denied_resources_truncated); } #[test] diff --git a/src/core/learning_mode_core/src/lib.rs b/src/core/learning_mode_core/src/lib.rs index e1e13688..0df6b84b 100644 --- a/src/core/learning_mode_core/src/lib.rs +++ b/src/core/learning_mode_core/src/lib.rs @@ -25,11 +25,10 @@ //! ## Mode caveat //! //! What a capture contains depends on the active OS learning mode. -//! File/path and UI denials are recorded under both `learningMode` -//! (`block`) and `permissiveLearningMode` (`allow`), but -//! **capability** ([`ResourceType::Capability`]) denials are currently -//! only recorded under permissive learning mode. Consumers must not -//! assume capability records are present under plain `learningMode`. +//! 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)] @@ -39,7 +38,7 @@ pub mod frame; pub mod model; pub mod summary; -pub use analyze::{AnalyzeError, DenialAnalyzer}; +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}; diff --git a/src/core/learning_mode_core/src/model.rs b/src/core/learning_mode_core/src/model.rs index 5cc2a60b..ab0990a8 100644 --- a/src/core/learning_mode_core/src/model.rs +++ b/src/core/learning_mode_core/src/model.rs @@ -32,8 +32,8 @@ pub enum ResourceType { /// current Windows backend does not yet produce this variant. Network, /// A named OS capability (AppContainer / brokered capability) the - /// workload was denied. Only produced under permissive learning - /// mode today — see the mode caveat in the crate docs. + /// 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, From c77b2cd202aebbdb7bfe500f29d2ebeb7cfebe74 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 13:17:23 -0700 Subject: [PATCH 07/12] Fix TDH buffer alignment Allocate TRACE_EVENT_INFO storage with its required alignment and decode UTF-16 payloads from byte pairs so unaligned ETW data never forms invalid Rust references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../learning_mode/windows/src/tdh_decode.rs | 93 +++++++++++++++++-- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/backends/learning_mode/windows/src/tdh_decode.rs b/src/backends/learning_mode/windows/src/tdh_decode.rs index 839287ba..94eaed74 100644 --- a/src/backends/learning_mode/windows/src/tdh_decode.rs +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -36,6 +36,37 @@ const PROPERTY_PARAM_LENGTH: i32 = 0x2; const PROPERTY_PARAM_COUNT: i32 = 0x4; const MAX_PROPERTY_ELEMENTS: usize = 4096; +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) } + } +} + /// Decodes an `EVENT_RECORD` into `DecodedEventParts`. /// /// Returns `None` when TDH can't describe the event (rare — usually @@ -57,8 +88,8 @@ pub unsafe fn decode_event_parts( )); } - let mut buffer = vec![0u8; buf_size as usize]; - let info_ptr = buffer.as_mut_ptr().cast::(); + 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 { @@ -71,7 +102,7 @@ pub unsafe fn decode_event_parts( let header = unsafe { (*event_record).EventHeader }; let event_id = header.EventDescriptor.Id; - let props = decode_properties(&buffer, info, event_record)?; + let props = decode_properties(buffer.as_bytes(), info, event_record)?; Ok(DecodedEventParts { provider: header.ProviderId, @@ -282,16 +313,26 @@ fn format_property_value( available }; let max_wchars = byte_len / 2; - // SAFETY: data is valid for `available` bytes; max_wchars - // bounds the slice. - let wchars = unsafe { std::slice::from_raw_parts(data.cast::(), max_wchars) }; - let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); - let s = String::from_utf16_lossy(&wchars[..len]); + // 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 { - ((len + 1).min(max_wchars)) * 2 + terminator_index.map_or(max_wchars * 2, |index| (index + 1) * 2) }; (format!("\"{s}\""), consumed) } @@ -438,6 +479,19 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { mod tests { use super::*; + #[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 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 @@ -466,6 +520,27 @@ mod tests { 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(); From 1fd65e6839fbfe5c63a35c4f7e15f90d5fdc07ac Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 14:38:10 -0700 Subject: [PATCH 08/12] Harden denial decoder contracts Stream raw ETL diagnostics, honor producer pointer width, normalize Windows path aliases, correct access masks and empty identifiers, and serialize FILETIME losslessly for JSON consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../windows/examples/lm_analyze.rs | 47 ++++-- .../learning_mode/windows/src/etl_decode.rs | 100 +++++++++---- .../learning_mode/windows/src/extractors.rs | 35 ++++- src/backends/learning_mode/windows/src/lib.rs | 4 +- .../learning_mode/windows/src/path_norm.rs | 44 ++++-- .../learning_mode/windows/src/tdh_decode.rs | 138 +++++++++++++----- src/core/learning_mode_core/src/frame.rs | 2 +- src/core/learning_mode_core/src/model.rs | 37 ++++- 8 files changed, 306 insertions(+), 101 deletions(-) diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index bfe01762..c282a223 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -35,7 +35,7 @@ mod windows_impl { use std::path::Path; use learning_mode_core::{emit, DenialAnalyzer, DenialSummary}; - use learning_mode_windows::{decode_raw_events, EtlDenialAnalyzer}; + use learning_mode_windows::{visit_raw_events, EtlDenialAnalyzer}; pub fn run() -> i32 { let args: Vec = std::env::args().skip(1).collect(); @@ -97,25 +97,40 @@ mod windows_impl { /// 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 events = match decode_raw_events(path) { - Ok(e) => e, - Err(e) => { - eprintln!("decode failed: {e}"); - return 1; + 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; + } } }; - let mut histogram: BTreeMap = BTreeMap::new(); - let stdout = std::io::stdout(); - let mut out = stdout.lock(); - for ev in &events { - *histogram.entry(ev.event_id).or_default() += 1; - let props: Vec = ev.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); - let _ = writeln!(out, "event {} | {}", ev.event_id, props.join(" | ")); + if writeln!(out, "--- {event_count} event(s) total ---").is_err() { + return 1; } - let _ = writeln!(out, "--- {} event(s) total ---", events.len()); - for (id, count) in &histogram { - let _ = writeln!(out, " id {id}: {count}"); + 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 index a6a23bad..04110f65 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -47,33 +47,45 @@ enum CollectionMode { Raw, } -/// Accumulates bounded analysis results or raw diagnostic events during a -/// `ProcessTrace` pass. -struct Accumulator { +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_events: Vec, + raw_visitor: Option<&'visitor mut RawEventVisitor<'visitor>>, + raw_event_count: usize, decode_error: Option, + visitor_panic: Option>, } -impl Accumulator { +impl<'visitor> Accumulator<'visitor> { fn analyze() -> Self { Self { mode: CollectionMode::Analyze, denials: Vec::new(), seen: HashSet::new(), truncated: false, - raw_events: Vec::new(), + raw_visitor: None, + raw_event_count: 0, decode_error: None, + visitor_panic: None, } } - fn raw() -> Self { + fn raw(visitor: &'visitor mut RawEventVisitor<'visitor>) -> Self { Self { mode: CollectionMode::Raw, - ..Self::analyze() + denials: Vec::new(), + seen: HashSet::new(), + truncated: false, + raw_visitor: Some(visitor), + raw_event_count: 0, + decode_error: None, + visitor_panic: None, } } @@ -103,6 +115,19 @@ impl Accumulator { }); } + 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)); @@ -120,7 +145,9 @@ pub struct EtlDenialAnalyzer; impl DenialAnalyzer for EtlDenialAnalyzer { fn analyze(&self, source_path: &Path) -> Result { - process_trace_file(source_path, CollectionMode::Analyze)?.into_analysis() + let mut accumulator = Accumulator::analyze(); + process_trace_file(source_path, &mut accumulator)?; + accumulator.into_analysis() } } @@ -139,18 +166,23 @@ fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { ) } -/// Decodes every event in the ETL into `(event_id, props)` pairs, for -/// schema discovery / diagnostics. Preserves the on-disk order. +/// 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 decode_raw_events(source_path: &Path) -> Result, AnalyzeError> { - let accumulator = process_trace_file(source_path, CollectionMode::Raw)?; +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_events) + Ok(accumulator.raw_event_count) } /// De-duplicates raw denials by `(user-visible path, accessType)`, @@ -171,8 +203,8 @@ fn dedup_to_resources>(raws: I) -> AnalysisRes /// completion, and returns the decoded events. fn process_trace_file( source_path: &Path, - mode: CollectionMode, -) -> Result { + 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 { @@ -186,16 +218,11 @@ fn process_trace_file( .chain(std::iter::once(0)) .collect(); - let mut accumulator = match mode { - CollectionMode::Analyze => Accumulator::analyze(), - CollectionMode::Raw => Accumulator::raw(), - }; - 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::addr_of_mut!(accumulator).cast(); + 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 @@ -220,6 +247,10 @@ fn process_trace_file( 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 { @@ -230,7 +261,7 @@ fn process_trace_file( ))); } - Ok(accumulator) + Ok(()) } /// ETW record callback, invoked by `ProcessTrace` for every event in the @@ -246,7 +277,7 @@ unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) } // 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; + let context = unsafe { (*event_record).UserContext } as *mut Accumulator<'_>; if context.is_null() { return; } @@ -255,6 +286,9 @@ unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) // 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; @@ -270,7 +304,7 @@ unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) acc.add_raw_denial(raw); } } - CollectionMode::Raw => acc.raw_events.push(parts), + CollectionMode::Raw => acc.visit_raw_event(&parts), }, Err(error) => { if acc.decode_error.is_none() { @@ -286,6 +320,22 @@ 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, diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index b611e3fe..b41e7012 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -152,10 +152,7 @@ pub fn build_denial_from_access_check( let object_name = find_prop(&parts.props, "ObjectName") .map(|v| v.trim_matches('"').to_string()) - .unwrap_or_default(); - if resource_type == ResourceType::Capability && object_name.is_empty() { - return None; - } + .filter(|name| !name.is_empty())?; let access_type = if resource_type == ResourceType::Capability { // Capability checks report a mask (often 0x1) that is not a @@ -330,7 +327,12 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { const KEY_NOTIFY: u32 = 0x0010; const KEY_CREATE_LINK: u32 = 0x0020; ( - KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | READ_CONTROL | GENERIC_READ, + 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 @@ -338,7 +340,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { | GENERIC_WRITE | GENERIC_ALL, // Registry has no execute concept (KEY_EXECUTE aliases KEY_READ). - GENERIC_EXECUTE, + 0, ) } else { // File/directory-specific rights (winnt.h FILE_*). @@ -348,6 +350,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { 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; ( @@ -355,6 +358,7 @@ fn access_type_from_mask(mask: u32, is_registry: bool) -> AccessType { FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA + | FILE_DELETE_CHILD | FILE_WRITE_ATTRIBUTES | standard_write | GENERIC_WRITE @@ -497,6 +501,21 @@ mod tests { 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( @@ -627,6 +646,7 @@ mod tests { ); // 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 @@ -662,7 +682,8 @@ mod tests { // 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!) - // KEY_SET_VALUE / CREATE_SUB_KEY / CREATE_LINK are writes. + 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. diff --git a/src/backends/learning_mode/windows/src/lib.rs b/src/backends/learning_mode/windows/src/lib.rs index 701dca44..294caa9e 100644 --- a/src/backends/learning_mode/windows/src/lib.rs +++ b/src/backends/learning_mode/windows/src/lib.rs @@ -45,7 +45,9 @@ mod path_norm; mod tdh_decode; #[cfg(target_os = "windows")] -pub use etl_decode::{decode_raw_events, EtlDenialAnalyzer}; +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 index 30c620b3..f6227172 100644 --- a/src/backends/learning_mode/windows/src/path_norm.rs +++ b/src/backends/learning_mode/windows/src/path_norm.rs @@ -5,15 +5,15 @@ //! //! ETW emits filesystem paths in two kernel forms: //! -//! - the NT object-manager DOS-devices form `\??\C:\Users\foo\file.txt` -//! (and `\??\UNC\server\share` for network paths), and +//! - 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 `\??\` form is a pure textual prefix strip. For the `\Device\` form -//! we walk `A:` through `Z:` calling `QueryDosDeviceW` to discover each +//! 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`), 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 @@ -34,17 +34,24 @@ 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 -/// object-manager DOS-devices prefix (`\??\C:\...`, `\??\UNC\...`) or a +/// DOS-devices prefix (`\??\`, `\\?\`, or `\\.\`) or a /// known `\Device\HarddiskVolumeN\...` prefix. Returns `None` when the path /// is not a filesystem path that can be canonicalized (registry, MUP, /// unknown device). Callers should fall back to the original input on /// `None`. pub fn to_user_visible(kernel_path: &str) -> Option { - // NT object-manager DOS-devices prefix: `\??\C:\...` is exactly - // `C:\...`, and `\??\UNC\server\share` is `\\server\share`. This is a - // pure textual mapping that needs no device lookup. - if let Some(rest) = kernel_path.strip_prefix(r"\??\") { - if let Some(unc) = rest.strip_prefix(r"UNC\") { + // 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()); @@ -134,6 +141,23 @@ mod tests { ); } + #[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 drive_map_populates() { // On any Windows machine running tests there is at least one volume diff --git a/src/backends/learning_mode/windows/src/tdh_decode.rs b/src/backends/learning_mode/windows/src/tdh_decode.rs index 94eaed74..5320dfb1 100644 --- a/src/backends/learning_mode/windows/src/tdh_decode.rs +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -35,6 +35,7 @@ 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>, @@ -102,7 +103,8 @@ pub unsafe fn decode_event_parts( let header = unsafe { (*event_record).EventHeader }; let event_id = header.EventDescriptor.Id; - let props = decode_properties(buffer.as_bytes(), info, event_record)?; + 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, @@ -115,6 +117,7 @@ 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. @@ -131,38 +134,42 @@ fn decode_properties( 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 { - let value = decode_property( - i, - info_buf, - info, - user_data, - user_data_len, - &mut offset, - &mut numeric_values, - )?; + let value = decode_property(i, &context, &mut offset, &mut numeric_values)?; results.push(value); } Ok(results) } -fn decode_property( - index: usize, - info_buf: &[u8], - info: &TRACE_EVENT_INFO, +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, String), String> { - if index >= info.PropertyCount as usize { + if index >= context.info.PropertyCount as usize { return Err(format!("property index {index} is out of range")); } - let prop_info = event_property_info(info, index); - let prop_name = - wide_str_at(info_buf, prop_info.NameOffset).unwrap_or_else(|| format!("prop{index}")); + 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 { @@ -176,15 +183,7 @@ fn decode_property( let member_count = unsafe { prop_info.Anonymous1.structType.NumOfStructMembers } as usize; for _ in 0..count { for child_index in start_index..start_index + member_count { - let _ = decode_property( - child_index, - info_buf, - info, - user_data, - user_data_len, - offset, - numeric_values, - )?; + let _ = decode_property(child_index, context, offset, numeric_values)?; } } return Ok((prop_name, "".to_string())); @@ -200,21 +199,27 @@ fn decode_property( let mut values = Vec::with_capacity(count.min(available_element_bound( in_type, - user_data_len.saturating_sub(*offset), + context.user_data_len.saturating_sub(*offset), + context.pointer_size, ))); let mut numeric_value = None; for _ in 0..count { - let remaining = user_data_len.saturating_sub(*offset); + 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 { user_data.add(*offset) } + unsafe { context.user_data.add(*offset) } } else { std::ptr::null() }; - let (value, consumed) = - format_property_value(in_type, declared_length, data_ptr, remaining); + 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" @@ -252,12 +257,21 @@ fn resolve_property_count( } } -fn available_element_bound(in_type: u16, available: usize) -> usize { +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_INT64 | TDH_INTYPE_UINT64 | TDH_INTYPE_POINTER | TDH_INTYPE_HEXINT64 => 8, + TDH_INTYPE_POINTER => pointer_size, + TDH_INTYPE_INT64 | TDH_INTYPE_UINT64 | TDH_INTYPE_HEXINT64 => 8, _ => 1, }; (available / element_size).max(1) @@ -295,11 +309,12 @@ fn parse_numeric_metadata(value: &str) -> Option { .or_else(|| value.parse().ok()) } -fn format_property_value( +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); @@ -398,10 +413,11 @@ fn format_property_value( format!("{:#x}", unsafe { (data.cast::()).read_unaligned() }), 8, ), - TDH_INTYPE_POINTER if available >= 8 => ( - // 64-bit pointer; on 32-bit hosts this would be 4 bytes but - // we only target x64. Unaligned read because ETW payload - // alignment is not guaranteed. + 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, ), @@ -413,6 +429,16 @@ fn format_property_value( } } +#[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 { @@ -559,6 +585,40 @@ mod tests { 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]; diff --git a/src/core/learning_mode_core/src/frame.rs b/src/core/learning_mode_core/src/frame.rs index 4969d0eb..2e42b126 100644 --- a/src/core/learning_mode_core/src/frame.rs +++ b/src/core/learning_mode_core/src/frame.rs @@ -9,7 +9,7 @@ //! positional assumptions: //! //! ```text -//! {"type":"denial","path":"...","resourceType":"file","accessType":"read","pid":123,"filetime":...} +//! {"type":"denial","path":"...","resourceType":"file","accessType":"read","pid":123,"filetime":"..."} //! {"type":"denial", ...} //! {"type":"summary","exitCode":0,"totalDenials":2,"deniedResourcesTruncated":false} //! ``` diff --git a/src/core/learning_mode_core/src/model.rs b/src/core/learning_mode_core/src/model.rs index ab0990a8..2b31978a 100644 --- a/src/core/learning_mode_core/src/model.rs +++ b/src/core/learning_mode_core/src/model.rs @@ -16,6 +16,28 @@ 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 @@ -98,7 +120,9 @@ pub struct DeniedResource { /// (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 field uniformly. The JSON wire format uses a decimal string so + /// JavaScript consumers do not lose precision. + #[serde(with = "decimal_u64")] pub filetime: u64, } @@ -139,7 +163,7 @@ mod tests { assert!(json.contains("\"resourceType\":\"file\""), "got {json}"); assert!(json.contains("\"accessType\":\"read\""), "got {json}"); assert!( - json.contains("\"filetime\":132847890123456789"), + json.contains("\"filetime\":\"132847890123456789\""), "got {json}" ); } @@ -158,6 +182,15 @@ mod tests { 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 [ From 206750e69fda65c6412936bf6359252b8c3317f4 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 15:45:41 -0700 Subject: [PATCH 09/12] Use RFC 7464 terminology Describe the record-separator-framed output as an RFC 7464 JSON text sequence rather than NDJSON across the core API and diagnostic example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../learning_mode/windows/examples/lm_analyze.rs | 12 ++++++------ src/core/learning_mode_core/src/analyze.rs | 2 +- src/core/learning_mode_core/src/emit.rs | 2 +- src/core/learning_mode_core/src/frame.rs | 2 +- src/core/learning_mode_core/src/lib.rs | 4 ++-- src/core/learning_mode_core/src/model.rs | 2 +- src/core/learning_mode_core/src/summary.rs | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index c282a223..dcb998b4 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Decode a sealed learning-mode `.etl` into the captureDenials NDJSON -//! output stream, or dump its raw ETW events for schema discovery. +//! Decode a sealed learning-mode `.etl` into the captureDenials RFC 7464 +//! JSON text sequence, or dump its raw ETW events for schema discovery. //! //! Usage: //! //! ```text -//! # Emit the DeniedResource NDJSON stream (0x1E-framed) to stdout: +//! # 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): @@ -53,7 +53,7 @@ mod windows_impl { eprintln!("--exit-code is required unless --raw is used"); return 2; }; - emit_ndjson(path, exit_code) + emit_json_sequence(path, exit_code) } } @@ -62,8 +62,8 @@ mod windows_impl { args.get(index + 1)?.parse().ok() } - /// Decodes denials and writes the 0x1E-framed NDJSON stream to stdout. - fn emit_ndjson(path: &Path, exit_code: i32) -> i32 { + /// 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) => { diff --git a/src/core/learning_mode_core/src/analyze.rs b/src/core/learning_mode_core/src/analyze.rs index 314091cf..74ff2d17 100644 --- a/src/core/learning_mode_core/src/analyze.rs +++ b/src/core/learning_mode_core/src/analyze.rs @@ -64,7 +64,7 @@ pub enum AnalyzeError { /// /// 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 NDJSON stream via +/// [`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 diff --git a/src/core/learning_mode_core/src/emit.rs b/src/core/learning_mode_core/src/emit.rs index 2398836e..c14e7c87 100644 --- a/src/core/learning_mode_core/src/emit.rs +++ b/src/core/learning_mode_core/src/emit.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Framed NDJSON emitter for captureDenials output files. +//! 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 diff --git a/src/core/learning_mode_core/src/frame.rs b/src/core/learning_mode_core/src/frame.rs index 2e42b126..2505caae 100644 --- a/src/core/learning_mode_core/src/frame.rs +++ b/src/core/learning_mode_core/src/frame.rs @@ -27,7 +27,7 @@ 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 NDJSON +/// 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")] diff --git a/src/core/learning_mode_core/src/lib.rs b/src/core/learning_mode_core/src/lib.rs index 0df6b84b..bde6a79e 100644 --- a/src/core/learning_mode_core/src/lib.rs +++ b/src/core/learning_mode_core/src/lib.rs @@ -12,13 +12,13 @@ //! [`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 NDJSON output file that host applications read to +//! 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 NDJSON [`emit`]ter, and the [`DenialAnalyzer`] decode trait. +//! 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. //! diff --git a/src/core/learning_mode_core/src/model.rs b/src/core/learning_mode_core/src/model.rs index 2b31978a..a5b8f793 100644 --- a/src/core/learning_mode_core/src/model.rs +++ b/src/core/learning_mode_core/src/model.rs @@ -6,7 +6,7 @@ //! [`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 NDJSON output file (see [`crate::emit`]) is just a framed +//! 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 diff --git a/src/core/learning_mode_core/src/summary.rs b/src/core/learning_mode_core/src/summary.rs index 96629533..a7401369 100644 --- a/src/core/learning_mode_core/src/summary.rs +++ b/src/core/learning_mode_core/src/summary.rs @@ -4,7 +4,7 @@ //! Terminating summary frame for a captureDenials output stream. //! //! Exactly one [`DenialSummary`] is written after the last -//! [`crate::model::DeniedResource`] in an NDJSON output file. It gives +//! [`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"). From 4739ac945d7ed15374ae6695fd2c1b4f3cd806b3 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 21:40:11 -0700 Subject: [PATCH 10/12] Enforce canonical denial file paths Normalize DOS devices, mapped drives, MUP redirectors, and DFS paths to absolute user-visible forms while omitting pipes, relative paths, and unknown device namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../learning_mode/windows/src/etl_decode.rs | 42 ++++++- .../learning_mode/windows/src/path_norm.rs | 116 ++++++++++++++++-- 2 files changed, 145 insertions(+), 13 deletions(-) diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index 04110f65..2ae00029 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -90,8 +90,18 @@ impl<'visitor> Accumulator<'visitor> { } fn add_raw_denial(&mut self, raw: RawDenial) { - let path = - path_norm::to_user_visible(&raw.object_name).unwrap_or_else(|| raw.object_name.clone()); + 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() @@ -374,6 +384,34 @@ mod tests { 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![ diff --git a/src/backends/learning_mode/windows/src/path_norm.rs b/src/backends/learning_mode/windows/src/path_norm.rs index f6227172..4eb35caf 100644 --- a/src/backends/learning_mode/windows/src/path_norm.rs +++ b/src/backends/learning_mode/windows/src/path_norm.rs @@ -14,13 +14,14 @@ //! //! 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`), then check the input +//! 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). //! -//! Non-file paths (registry `\REGISTRY\Machine\...`, MUP / network shares, -//! etc.) are returned unchanged. +//! MUP redirector paths are converted to UNC paths. Non-file paths such as +//! registry `\REGISTRY\Machine\...` are not recognized. use std::sync::OnceLock; @@ -35,10 +36,9 @@ static DRIVE_MAP: OnceLock> = OnceLock::new(); /// /// Returns `Some(canonical)` when the input starts with the NT /// DOS-devices prefix (`\??\`, `\\?\`, or `\\.\`) or a -/// known `\Device\HarddiskVolumeN\...` prefix. Returns `None` when the path -/// is not a filesystem path that can be canonicalized (registry, MUP, -/// unknown device). Callers should fall back to the original input on -/// `None`. +/// 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. @@ -57,12 +57,41 @@ pub fn to_user_visible(kernel_path: &str) -> Option { 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('\\') { @@ -73,6 +102,27 @@ pub fn to_user_visible(kernel_path: &str) -> Option { 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)> { @@ -103,9 +153,10 @@ fn load_drive_map() -> Vec<(String, String)> { .unwrap_or(n as usize); let device = String::from_utf16_lossy(&buf[..end]); - // Common shapes: `\Device\HarddiskVolume3`, `\Device\Mup`, etc. - // Only canonicalize filesystem volumes; skip non-Harddisk entries. - if device.starts_with(r"\Device\HarddiskVolume") { + // 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)); } } @@ -146,6 +197,7 @@ mod tests { 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", @@ -158,6 +210,39 @@ mod tests { } } + #[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 @@ -166,7 +251,16 @@ mod tests { let map = rebuild_drive_map_for_tests(); assert!( !map.is_empty(), - "drive map should have at least one HarddiskVolume entry" + "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") ); } From 767bf87d86109b0566ee0e761a0a247266b5e613 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 29 Jul 2026 22:04:49 -0700 Subject: [PATCH 11/12] Clarify decoder diagnostic boundaries Document lm_analyze as a developer-only tool and explain why the sealed, policy-oriented learning-mode decoder does not directly reuse the diagnostic console's real-time display consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dfea4d42-e8a5-42ef-978a-6434e8bf89ec --- .../learning_mode/windows/examples/lm_analyze.rs | 4 ++++ src/backends/learning_mode/windows/src/etl_decode.rs | 9 +++++++++ src/backends/learning_mode/windows/src/extractors.rs | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/src/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index dcb998b4..31390238 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -4,6 +4,10 @@ //! 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 +//! and consumes the trace automatically during normal `captureDenials` use. +//! //! Usage: //! //! ```text diff --git a/src/backends/learning_mode/windows/src/etl_decode.rs b/src/backends/learning_mode/windows/src/etl_decode.rs index 2ae00029..1ab062af 100644 --- a/src/backends/learning_mode/windows/src/etl_decode.rs +++ b/src/backends/learning_mode/windows/src/etl_decode.rs @@ -14,6 +14,15 @@ //! [`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; diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index b41e7012..6ba29f98 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -10,6 +10,12 @@ //! 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 From af3d5bcbd28773a3c8b8bf305c480f271810481f Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 30 Jul 2026 11:04:04 -0700 Subject: [PATCH 12/12] Address PR 709 review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a8860a9-bb20-48ef-91a1-3da8e34b92fb --- .github/copilot-instructions.md | 7 +- .../windows/examples/lm_analyze.rs | 3 +- .../learning_mode/windows/src/extractors.rs | 18 ++-- .../learning_mode/windows/src/tdh_decode.rs | 91 +++++++++++++++++-- 4 files changed, 103 insertions(+), 16 deletions(-) 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/backends/learning_mode/windows/examples/lm_analyze.rs b/src/backends/learning_mode/windows/examples/lm_analyze.rs index 31390238..51161e8b 100644 --- a/src/backends/learning_mode/windows/examples/lm_analyze.rs +++ b/src/backends/learning_mode/windows/examples/lm_analyze.rs @@ -6,7 +6,8 @@ //! //! This is a developer diagnostic for inspecting captured traces manually. //! End users and SDK agents do not invoke it: the BaseContainer runner seals -//! and consumes the trace automatically during normal `captureDenials` use. +//! the trace and reports its path, while this example performs the manual +//! analysis until runner integration consumes the trace automatically. //! //! Usage: //! diff --git a/src/backends/learning_mode/windows/src/extractors.rs b/src/backends/learning_mode/windows/src/extractors.rs index 6ba29f98..e28ad537 100644 --- a/src/backends/learning_mode/windows/src/extractors.rs +++ b/src/backends/learning_mode/windows/src/extractors.rs @@ -231,12 +231,12 @@ pub fn build_denial_from_capability( pid: u32, filetime: u64, ) -> Option { - // If the record explicitly reports the check as not-denied, skip it; - // absence of the field is treated as a denial (fail open on decode gap). - if let Some(denied) = find_prop(&parts.props, "Denied") { - if !denied.trim_matches('"').eq_ignore_ascii_case("true") { - return None; - } + // 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") @@ -592,6 +592,12 @@ mod tests { 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( diff --git a/src/backends/learning_mode/windows/src/tdh_decode.rs b/src/backends/learning_mode/windows/src/tdh_decode.rs index 5320dfb1..8b7bb22b 100644 --- a/src/backends/learning_mode/windows/src/tdh_decode.rs +++ b/src/backends/learning_mode/windows/src/tdh_decode.rs @@ -66,6 +66,13 @@ impl TdhInfoBuffer { // `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`. @@ -143,8 +150,12 @@ fn decode_properties( }; for i in 0..prop_count { - let value = decode_property(i, &context, &mut offset, &mut numeric_values)?; - results.push(value); + results.extend(decode_property( + i, + &context, + &mut offset, + &mut numeric_values, + )?); } Ok(results) @@ -163,7 +174,7 @@ fn decode_property( context: &PropertyDecodeContext<'_>, offset: &mut usize, numeric_values: &mut [Option], -) -> Result<(String, String), String> { +) -> Result, String> { if index >= context.info.PropertyCount as usize { return Err(format!("property index {index} is out of range")); } @@ -181,12 +192,18 @@ fn decode_property( 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 { - let _ = decode_property(child_index, context, offset, numeric_values)?; + values.extend(decode_property( + child_index, + context, + offset, + numeric_values, + )?); } } - return Ok((prop_name, "".to_string())); + return Ok(values); } let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; @@ -239,7 +256,7 @@ fn decode_property( } else { format!("[{}]", values.join(", ")) }; - Ok((prop_name, rendered)) + Ok(vec![(prop_name, rendered)]) } fn resolve_property_count( @@ -505,6 +522,14 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { 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); @@ -518,6 +543,60 @@ mod tests { 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