From 1ebc297b8b5fe555bd681c09f6d4644fbb0fa818 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 8 Jul 2026 20:09:37 -0600 Subject: [PATCH 01/10] feat(mxc): ETW->OCSF audit consumer + Windows OCSF JSONL parity (cp6 P1) Add a Windows MXC ETW->OCSF audit trail in openshell-driver-mxc: a real-time Sandboxing-provider ETW consumer that decodes events (TDH), attributes each to an OpenShell sandbox_id, and maps them to OCSF (lifecycle 6002, config 5019, process 1007, finding 2004). cp6 Phase 1 - durable OCSF JSONL audit-file parity with Linux: - openshell-ocsf: add emit_ocsf_event_routed (populates the event-bridge thread-local AND stamps sandbox_id+message in one dispatch) plus public set/clear_current_event; OS-aware device (Device::windows/for_current_os) so device.os.name reflects the host instead of a hardcoded Linux stub. - etw_consumer: emit via the routed emit (previously fired a bare info! that never populated the bridge, so the structured event was dropped). - openshell-server: install OcsfJsonlLayer over a synchronous daily-rotated appender (durable under force-kill), gated by OPENSHELL_OCSF_JSON, path via %PROGRAMDATA%\OpenShell\logs (override OPENSHELL_OCSF_LOG_DIR). - device.hostname now resolves to the real gateway machine name. Box-proven on 7F203-MXC-001: JSONL lines == shorthand OCSF rows, all valid OCSF JSON, per-sandbox attribution intact, disabled state writes nothing. Signed-off-by: Akber Raza --- Cargo.lock | 3 + Cargo.toml | 3 + crates/openshell-driver-mxc/Cargo.toml | 9 + crates/openshell-driver-mxc/src/driver.rs | 54 + .../openshell-driver-mxc/src/etw_consumer.rs | 1235 +++++++++++++++++ crates/openshell-driver-mxc/src/lib.rs | 5 + crates/openshell-ocsf/src/builders/mod.rs | 5 +- crates/openshell-ocsf/src/lib.rs | 3 +- crates/openshell-ocsf/src/objects/device.rs | 28 + .../src/tracing_layers/event_bridge.rs | 107 +- .../openshell-ocsf/src/tracing_layers/mod.rs | 5 +- crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/tracing_setup.rs | 93 ++ 13 files changed, 1544 insertions(+), 7 deletions(-) create mode 100644 crates/openshell-driver-mxc/src/etw_consumer.rs diff --git a/Cargo.lock b/Cargo.lock index 011730760d..ba10b9f01a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4029,6 +4029,7 @@ dependencies = [ "base64 0.22.1", "futures", "openshell-core", + "openshell-ocsf", "openshell-policy", "serde", "serde_json", @@ -4040,6 +4041,7 @@ dependencies = [ "tonic", "tracing", "uuid", + "windows", ] [[package]] @@ -4415,6 +4417,7 @@ dependencies = [ "tower 0.5.3", "tower-http 0.6.8", "tracing", + "tracing-appender", "tracing-opentelemetry", "tracing-subscriber", "url", diff --git a/Cargo.toml b/Cargo.toml index 57b77d0716..b2593b2484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ terminal-colorsaurus = "1.0" # Error handling miette = { version = "7", features = ["fancy"] } thiserror = "2" + +# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only) +windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] } anyhow = "1" # Logging/Tracing diff --git a/crates/openshell-driver-mxc/Cargo.toml b/crates/openshell-driver-mxc/Cargo.toml index 2f82e50e3b..b05c7e82e1 100644 --- a/crates/openshell-driver-mxc/Cargo.toml +++ b/crates/openshell-driver-mxc/Cargo.toml @@ -15,6 +15,10 @@ name = "openshell_driver_mxc" [dependencies] openshell-core = { path = "../openshell-core" } +# OCSF builders + emit target used by the Windows ETW audit consumer. OS-agnostic +# crate (no windows deps), so safe to depend on from all targets; only the +# windows-gated `etw_consumer` module actually uses it. +openshell-ocsf = { path = "../openshell-ocsf" } tokio = { workspace = true } tonic = { workspace = true } futures = { workspace = true } @@ -26,6 +30,11 @@ tracing = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +# ETW/TDH real-time consumer (Plane A audit). Windows-only so the Linux/WSL +# build stays an empty stub. +[target.'cfg(target_os = "windows")'.dependencies] +windows = { workspace = true } + [dev-dependencies] tokio = { workspace = true } # tempfile is not a workspace dependency; 3.27 is already resolved in Cargo.lock. diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index d8e981a46d..97234ba792 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -68,6 +68,10 @@ pub struct MxcComputeConfig { /// Enable `--debug` flag on `wxc-exec` invocations. pub debug: bool, + /// Enable the in-process ETW → OCSF audit consumer (Plane A). Consumes the OS + /// Sandboxing provider MXC drives and emits OCSF into the gateway trail. + /// Requires the gateway account to be in "Performance Log Users" (or admin). + pub etw_audit: bool, } impl Default for MxcComputeConfig { @@ -80,6 +84,7 @@ impl Default for MxcComputeConfig { default_configuration_id: crate::mxc::DEFAULT_CONFIGURATION_ID.into(), debug: false, + etw_audit: false, } } } @@ -184,6 +189,17 @@ pub struct MxcComputeBackend { /// immediately before dispatching to this backend's `create_sandbox`, which /// removes/consumes it. pending_policies: Arc>>, + /// In-process ETW → OCSF audit consumer (Plane A). `Some` only when + /// `config.etw_audit` is set and the session started; kept alive here so it + /// stops when the backend is dropped (held purely for its `Drop`, hence + /// never read directly). + #[allow(dead_code)] + etw_session: Option, + /// Shared MXC-ETW → `sandbox_id` attribution index. Seeded by the driver + /// (`pid → sandbox_id`) as it launches sandboxes and read by the ETW + /// consumer thread to map/emit OCSF. `Arc` even when audit is off so the + /// launch path is branch-free. + attribution: Arc>, } impl std::fmt::Debug for MxcComputeBackend { @@ -271,6 +287,26 @@ impl MxcComputeBackend { pub fn new(config: MxcComputeConfig) -> Self { let invoker = WxcExecInvoker::new(&config.wxc_exec_path, config.debug); let (watch_tx, _) = broadcast::channel(256); + + // Start the Plane-A ETW → OCSF consumer if enabled. The consumer thread + // attributes each event to a `sandbox_id` via `attribution` (seeded by + // the launch path) and emits OCSF for the mapped classes. + // Failure is non-fatal — the driver still runs, just without ETW audit. + let attribution = Arc::new(std::sync::Mutex::new( + crate::etw_consumer::AttributionIndex::new(), + )); + let etw_session = if config.etw_audit { + match crate::etw_consumer::start_session(attribution.clone()) { + Ok(session) => Some(session), + Err(e) => { + warn!(error = %e, "MXC ETW audit consumer failed to start; continuing without it"); + None + } + } + } else { + None + }; + Self { invoker, config, @@ -280,6 +316,8 @@ impl MxcComputeBackend { // mapper before any MXC lifecycle side effects begin. policy_mapper: Arc::new(EmbeddedPolicyMapper), pending_policies: Arc::new(Mutex::new(HashMap::new())), + etw_session, + attribution, } } @@ -420,6 +458,7 @@ impl MxcComputeBackend { let config = self.config.clone(); let registry = self.registry.clone(); let watch_tx = self.watch_tx.clone(); + let attribution = self.attribution.clone(); let sandbox = sandbox.clone(); tokio::spawn(async move { run_lifecycle( @@ -427,6 +466,7 @@ impl MxcComputeBackend { config, registry, watch_tx, + attribution, sandbox, sandbox_config, mapped, @@ -558,6 +598,9 @@ impl MxcComputeBackend { let mut registry = self.registry.lock().await; if registry.remove(sandbox_id).is_some() { + if let Ok(mut idx) = self.attribution.lock() { + idx.forget(sandbox_id); + } let _ = self.watch_tx.send(deleted_event(sandbox_id.to_string())); return Ok(true); } @@ -619,6 +662,7 @@ async fn run_lifecycle( config: MxcComputeConfig, registry: Arc>>, watch_tx: Arc>, + attribution: Arc>, sandbox: DriverSandbox, sandbox_config: MxcSandboxConfig, mapped: MappedConfig, @@ -722,6 +766,16 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); + // Seed ETW attribution: the wxc-exec pid we just spawned is the + // collision-proof anchor that ties the `Sandboxing` provider's events back + // to this `sandbox_id` (command line is a fallback matcher). No-op unless + // the ETW consumer is running. + if let Some(pid) = child.id() { + if let Ok(mut idx) = attribution.lock() { + idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); + } + } + let ready_sandbox = make_sandbox_with_condition( &sandbox, &DriverCondition { diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs new file mode 100644 index 0000000000..6db6009a72 --- /dev/null +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -0,0 +1,1235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Real-time ETW → OCSF audit consumer for MXC (Plane A). +//! +//! MXC does not emit its own ETW; the events we consume are produced by the OS +//! **Sandboxing** TraceLogging provider (`{f6ec123e-…}`) as a side effect of the +//! AppContainer / `processcontainer` operations MXC drives. This module runs one +//! process-wide real-time trace session, decodes events via TDH, and (in later +//! checkpoints) attributes each to an OpenShell `sandbox_id` and emits OCSF +//! through the gateway's tracing sink (`TracingLogBus`). +//! +//! Two responsibilities are kept behind a clean internal seam so a future +//! crate-extraction is a move-file, not a rewrite: +//! 1. **capture + decode** (this module's `unsafe` TDH/ETW code) → produces a +//! neutral [`DecodedEtwEvent`]. Knows nothing about OCSF or the registry. +//! 2. **attribute + map + emit** (the `handler` closure passed to +//! [`start_session`]) → `DecodedEtwEvent` → registry lookup → OCSF. +//! +//! Ported from MXC's reference consumer +//! (`msft-mxc/src/tools/mxc_diagnostic_console/src/etw.rs`), trimmed to Plane A +//! (Sandboxing provider only — the Kernel-General provider needs privilege our +//! service account does not have and is not required for Plane A). +//! +//! Checkpoint 2: capture + decode only. `start_session`'s handler currently just +//! logs decoded events at `debug`. Attribution + OCSF mapping land in later +//! checkpoints, without touching the capture/decode seam below. + +// This module is a thin, self-contained wrapper over the Windows ETW/TDH C API, +// which is unavoidably `unsafe`. The workspace lint `unsafe_code = "warn"` is +// allowed here (and only here) rather than annotating dozens of FFI blocks; the +// unsafe surface is confined to this file behind the safe `start_session` API. +#![allow(unsafe_code)] +// Scaffold: OCSF emit/context helpers are unused until checkpoint 3. +#![allow(dead_code)] +// The following pedantic/nursery lints are inherent to decoding raw ETW records +// against Windows structs and are allowed for this FFI module only: +// - pointer casts over the `EVENT_TRACE_PROPERTIES` / TDH buffers (the documented +// Win32 pattern of a `Vec` backing a header struct), +// - width/sign casts on fixed, small size/level values, +// - GUID/brace text in doc comments. +#![allow( + clippy::cast_ptr_alignment, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::borrow_as_ptr, + clippy::ptr_as_ptr, + clippy::match_same_arms, + clippy::redundant_pub_crate, + clippy::doc_markdown +)] + +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use windows::Win32::Foundation::WIN32_ERROR; +use windows::Win32::System::Diagnostics::Etw::{ + CONTROLTRACE_HANDLE, CloseTrace, ControlTraceW, EVENT_HEADER, EVENT_HEADER_EXTENDED_DATA_ITEM, + EVENT_PROPERTY_INFO, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, + EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, EnableTraceEx2, OpenTraceW, + PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, ProcessTrace, StartTraceW, + TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, WNODE_FLAG_TRACED_GUID, +}; +use windows::core::{GUID, PCWSTR, PWSTR}; + +use openshell_ocsf::{ + ActionId, ActivityId, AppLifecycleBuilder, ConfigStateChangeBuilder, DetectionFindingBuilder, + DispositionId, FindingInfo, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SandboxContext, SecurityLevelId, SeverityId, StateId, StatusId, +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// OS ProcessModel/Sandboxing TraceLogging provider — the Plane-A source. +/// `{f6ec123e-314e-400b-9e0a-151365e23083}`. +pub(crate) const SANDBOXING_PROVIDER_GUID: GUID = + GUID::from_u128(0xf6ec123e_314e_400b_9e0a_151365e23083); + +/// Our real-time session name (distinct from MXC's diagnostic console session). +const SESSION_NAME: &str = "OpenShell-MXC-ETW"; + +/// `EVENT_CONTROL_CODE_ENABLE_PROVIDER`. +const EVENT_CONTROL_CODE_ENABLE_PROVIDER: u32 = 1; + +/// `TdhGetEventInformation` sizing probe returns this when asking for the buffer size. +const ERROR_INSUFFICIENT_BUFFER: u32 = 122; + +// TDH InType constants for property decoding. +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_FLOAT: u16 = 11; +const TDH_INTYPE_DOUBLE: u16 = 12; +const TDH_INTYPE_BOOLEAN: u16 = 13; +const TDH_INTYPE_GUID: u16 = 15; +const TDH_INTYPE_POINTER: u16 = 16; +const TDH_INTYPE_FILETIME: u16 = 17; +const TDH_INTYPE_HEXINT32: u16 = 20; +const TDH_INTYPE_HEXINT64: u16 = 21; + +// --------------------------------------------------------------------------- +// Neutral decoded event (the capture/decode → attribute/map seam) +// --------------------------------------------------------------------------- + +/// TraceLogging activity opcodes we care about. +const OPCODE_START: u8 = 1; +const OPCODE_STOP: u8 = 2; + +/// An owned, `Send` copy of a raw ETW event record, captured in the callback so +/// the (slow) TDH decode happens off the real-time `ProcessTrace` pump thread. +/// +/// Decoding inline in the callback made the pump fall behind during the +/// sandbox-create burst, and ETW silently dropped mid-burst events into +/// `RealTimeBuffersLost`. The callback now does only cheap byte copies and hands +/// off; the consumer thread reconstructs an [`EVENT_RECORD`] over these owned +/// buffers and decodes at leisure. TraceLogging events carry their schema in the +/// extended-data items, so those are deep-copied too (not just `UserData`). +struct RawEtwEvent { + header: EVENT_HEADER, + user_data: Vec, + /// Extended-data item headers (their `DataPtr` is re-pointed at `ext_bufs` + /// before decode). + ext_items: Vec, + /// Owned backing buffers for each extended-data item, index-aligned with + /// `ext_items`. + ext_bufs: Vec>, +} + +// SAFETY: every field is either a `Vec` or a POD Windows struct whose only +// address-like field (`EVENT_HEADER_EXTENDED_DATA_ITEM::DataPtr`, a `u64`) is +// re-pointed at our owned buffers on the consumer thread before use. No borrowed +// kernel pointers survive the callback, so this is sound to move across threads. +unsafe impl Send for RawEtwEvent {} + +/// A decoded ETW event, independent of OCSF and the driver registry. +#[derive(Debug, Clone)] +pub(crate) struct DecodedEtwEvent { + /// Provider that emitted the event. + pub provider: GUID, + /// TraceLogging event id. + pub event_id: u16, + /// Event level (1=crit … 5=verbose). + pub level: u8, + /// Activity opcode: 1=Start, 2=Stop, 0=Info (plain event). + pub opcode: u8, + /// Emitting process id. + pub process_id: u32, + /// ETW activity id (event header) — the cross-process/cross-event correlator + /// for payload-keyless events like `SandboxConfig`. + pub activity_id: GUID, + /// Event/task name from TDH, if present. + pub event_name: Option, + /// Top-level properties as `(name, value)`; string values keep TDH's quotes. + pub props: Vec<(String, String)>, +} + +impl DecodedEtwEvent { + /// Raw property value (may be quoted for string types), first match wins. + pub fn get(&self, key: &str) -> Option<&str> { + self.props + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + /// Property value with surrounding double-quotes trimmed (for string types). + pub fn get_unquoted(&self, key: &str) -> Option { + self.get(key) + .map(|v| v.trim_matches('"').to_string()) + .filter(|s| !s.is_empty()) + } + + /// The MXC sandbox identity, if this event carries a non-empty one. + pub fn identity(&self) -> Option { + self.get_unquoted("identity") + } + + /// The Correlation-Vector base (`.` → ``) from `__TlgCV__` or + /// `correlationVector`, if present. A cross-event correlator MXC stamps on + /// most (not all) events. + pub fn cv_base(&self) -> Option { + self.get_unquoted("__TlgCV__") + .or_else(|| self.get_unquoted("correlationVector")) + .map(|cv| cv.split('.').next().unwrap_or(&cv).to_string()) + .filter(|s| !s.is_empty()) + } + + /// Compact `name { k=v, k=v }` rendering for debug logging. + pub fn summary(&self) -> String { + let name = self.event_name.as_deref().unwrap_or(""); + if self.props.is_empty() { + format!("{name} (id={})", self.event_id) + } else { + let joined: Vec = self.props.iter().map(|(k, v)| format!("{k}={v}")).collect(); + format!("{name} (id={}) {{ {} }}", self.event_id, joined.join(", ")) + } + } +} + +// --------------------------------------------------------------------------- +// Session handle (RAII) +// --------------------------------------------------------------------------- + +/// A running real-time ETW session plus its worker threads. Dropping (or calling +/// [`EtwSession::stop`]) stops the session and joins the threads. +pub(crate) struct EtwSession { + handle: u64, + pump_thread: Option>, + consumer_thread: Option>, +} + +impl EtwSession { + /// Stop the session and join worker threads. Idempotent. + pub fn stop(&mut self) { + if self.handle != 0 { + stop_session(self.handle); + self.handle = 0; + } + // ControlTraceW(STOP) makes ProcessTrace return → the pump thread ends and + // drops the boxed Sender → the consumer thread's `for ev in rx` ends. + if let Some(t) = self.pump_thread.take() { + let _ = t.join(); + } + if let Some(t) = self.consumer_thread.take() { + let _ = t.join(); + } + } +} + +impl Drop for EtwSession { + fn drop(&mut self) { + self.stop(); + } +} + +/// Wrapper to move a raw `Sender` pointer across the thread boundary into the +/// blocking `ProcessTrace` worker. SAFETY: the boxed `Sender` lives until the +/// worker reclaims it after `ProcessTrace` returns. +struct SendPtr(*mut mpsc::Sender); +unsafe impl Send for SendPtr {} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Start the real-time ETW session on the Sandboxing provider. Every decoded +/// event is attributed (via `index`) and mapped to OCSF on a dedicated consumer +/// thread. The driver seeds `index` (pid → sandbox_id) as it launches sandboxes. +/// +/// Returns an [`EtwSession`] that must be kept alive; dropping it stops capture. +pub(crate) fn start_session(index: Arc>) -> Result { + cleanup_stale_session(); + + let handle = start_trace_session()?; + enable_provider(handle)?; + + let (tx, rx) = mpsc::channel::(); + + let consumer_thread = std::thread::Builder::new() + .name("etw-ocsf-consumer".into()) + .spawn(move || { + // Decode off the pump thread: the callback only copies bytes, so the + // real-time buffers drain fast and the create burst isn't dropped. + for mut raw in rx { + match decode_raw(&mut raw) { + Some(ev) => process_event(&index, ev), + None => tracing::debug!( + target: "mxc_etw", + id = raw.header.EventDescriptor.Id, + opcode = raw.header.EventDescriptor.Opcode, + pid = raw.header.ProcessId, + "TDH decode failed for event" + ), + } + } + }) + .map_err(|e| { + stop_session(handle); + format!("failed to spawn ETW consumer thread: {e}") + })?; + + let send_ptr = SendPtr(Box::into_raw(Box::new(tx))); + let pump_thread = std::thread::Builder::new() + .name("etw-ocsf-pump".into()) + .spawn(move || process_trace_loop(send_ptr)) + .map_err(|e| { + stop_session(handle); + format!("failed to spawn ETW pump thread: {e}") + })?; + + tracing::info!( + session = SESSION_NAME, + "MXC ETW→OCSF consumer started (Sandboxing provider)" + ); + + Ok(EtwSession { + handle, + pump_thread: Some(pump_thread), + consumer_thread: Some(consumer_thread), + }) +} + +// --------------------------------------------------------------------------- +// Session management +// --------------------------------------------------------------------------- + +fn session_name_wide() -> Vec { + SESSION_NAME + .encode_utf16() + .chain(std::iter::once(0)) + .collect() +} + +fn alloc_properties_buf() -> Vec { + let props_size = size_of::(); + let name_wide_len = SESSION_NAME.encode_utf16().count() + 1; + let name_bytes = name_wide_len * 2; + let total = props_size + name_bytes + 2; + + let mut buf = vec![0u8; total]; + let props = buf.as_mut_ptr().cast::(); + unsafe { + (*props).Wnode.BufferSize = total as u32; + (*props).LoggerNameOffset = props_size as u32; + (*props).LogFileNameOffset = (props_size + name_bytes) as u32; + } + buf +} + +fn start_trace_session() -> Result { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + (*props).Wnode.Flags = WNODE_FLAG_TRACED_GUID; + (*props).Wnode.ClientContext = 1; // QPC timestamps + (*props).LogFileMode = EVENT_TRACE_REAL_TIME_MODE; + // ETW uses per-processor buffers. A short sandbox-create burst can leave + // a low-volume buffer on one CPU unflushed until the session stops, + // intermittently dropping mid-stream events (e.g. SandboxConfig). A 1s + // flush timer forces every per-CPU buffer to deliver promptly; the + // buffer sizing gives headroom for the create burst. + (*props).BufferSize = 64; // KB per buffer + (*props).MinimumBuffers = 8; + (*props).MaximumBuffers = 64; + (*props).FlushTimer = 1; // seconds + } + + let mut handle = CONTROLTRACE_HANDLE::default(); + let status = unsafe { StartTraceW(&mut handle, PCWSTR(name.as_ptr()), props) }; + + if status != WIN32_ERROR(0) { + return Err(format!( + "StartTraceW failed: error {} (needs 'Performance Log Users' or admin)", + status.0 + )); + } + + Ok(handle.Value) +} + +fn enable_provider(session_handle: u64) -> Result<(), String> { + let h = CONTROLTRACE_HANDLE { + Value: session_handle, + }; + + let status = unsafe { + EnableTraceEx2( + h, + &SANDBOXING_PROVIDER_GUID, + EVENT_CONTROL_CODE_ENABLE_PROVIDER, + TRACE_LEVEL_VERBOSE as u8, + 0xFFFF_FFFF_FFFF_FFFF, // all keywords + 0, + 0, + None, + ) + }; + + if status != WIN32_ERROR(0) { + stop_session(session_handle); + return Err(format!( + "EnableTraceEx2 (Sandboxing provider) failed: error {}", + status.0 + )); + } + + Ok(()) +} + +fn stop_session(handle: u64) { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + let h = CONTROLTRACE_HANDLE { Value: handle }; + + unsafe { + let status = ControlTraceW(h, PCWSTR(name.as_ptr()), props, EVENT_TRACE_CONTROL_STOP); + // On a successful STOP the kernel fills the properties with final session + // stats. Surface EventsLost so lossy captures are never silent (an audit + // trail that silently drops events is worse than one that flags gaps). + if status == WIN32_ERROR(0) { + // EventsLost = kernel buffer overruns; RealTimeBuffersLost/LogBuffersLost + // = the real-time delivery queue overflowing because the consumer fell + // behind. The latter is what a slow callback causes, so surface all + // three — an audit trail that silently drops events is worse than one + // that flags gaps. + let events_lost = (*props).EventsLost; + let rt_lost = (*props).RealTimeBuffersLost; + let log_lost = (*props).LogBuffersLost; + if events_lost > 0 || rt_lost > 0 || log_lost > 0 { + tracing::warn!( + events_lost, + realtime_buffers_lost = rt_lost, + log_buffers_lost = log_lost, + session = SESSION_NAME, + "ETW session lost events (increase buffers / speed up consumer)" + ); + } else { + tracing::debug!(session = SESSION_NAME, "ETW session stopped; 0 events lost"); + } + } + } +} + +/// Best-effort stop of a same-named session left behind by a crashed run, so +/// `StartTraceW` doesn't fail with `ERROR_ALREADY_EXISTS`. +fn cleanup_stale_session() { + let name = session_name_wide(); + let mut buf = alloc_properties_buf(); + let props = buf.as_mut_ptr().cast::(); + + unsafe { + let _ = ControlTraceW( + CONTROLTRACE_HANDLE::default(), + PCWSTR(name.as_ptr()), + props, + EVENT_TRACE_CONTROL_STOP, + ); + } +} + +// --------------------------------------------------------------------------- +// ProcessTrace loop (dedicated blocking thread) +// --------------------------------------------------------------------------- + +#[allow(clippy::field_reassign_with_default)] +fn process_trace_loop(send_ptr: SendPtr) { + let tx_ptr = send_ptr.0; + let mut name = session_name_wide(); + + let mut logfile = EVENT_TRACE_LOGFILEW::default(); + logfile.LoggerName = PWSTR(name.as_mut_ptr()); + logfile.Anonymous1.ProcessTraceMode = + PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD; + logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); + logfile.Context = tx_ptr.cast::(); + + let trace_handle = unsafe { OpenTraceW(&mut logfile) }; + if trace_handle.Value == u64::MAX { + tracing::error!(err = %std::io::Error::last_os_error(), "ETW OpenTraceW failed"); + // Reclaim the boxed Sender so the consumer thread's channel closes. + unsafe { drop(Box::from_raw(tx_ptr)) }; + return; + } + + let _ = unsafe { ProcessTrace(&[trace_handle], None, None) }; + + unsafe { + let _ = CloseTrace(trace_handle); + drop(Box::from_raw(tx_ptr)); + } +} + +unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { + let event = unsafe { &*event_record }; + // Hot path — keep it minimal (decode runs on the consumer thread). We only + // enabled the Sandboxing provider, but guard anyway. + if event.EventHeader.ProviderId != SANDBOXING_PROVIDER_GUID { + return; + } + + // Hot path: copy raw bytes only, then hand off. No TDH decode here — keeping + // this callback cheap is what stops ETW dropping the create burst. + let tx = unsafe { &*(event.UserContext as *const mpsc::Sender) }; + let raw = unsafe { copy_raw(event_record) }; + let _ = tx.send(raw); +} + +/// Deep-copy a kernel `EVENT_RECORD` into an owned, `Send` [`RawEtwEvent`]. +/// Runs in the ETW callback, so it does the minimum: byte copies, no decode. +unsafe fn copy_raw(event_record: *const EVENT_RECORD) -> RawEtwEvent { + let ev = unsafe { &*event_record }; + let header = ev.EventHeader; + + let ulen = ev.UserDataLength as usize; + let user_data = if ev.UserData.is_null() || ulen == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(ev.UserData.cast::(), ulen) }.to_vec() + }; + + let ext_count = ev.ExtendedDataCount as usize; + let mut ext_items = Vec::with_capacity(ext_count); + let mut ext_bufs = Vec::with_capacity(ext_count); + if !ev.ExtendedData.is_null() { + for i in 0..ext_count { + let item = unsafe { *ev.ExtendedData.add(i) }; + let dsize = item.DataSize as usize; + let buf = if item.DataPtr == 0 || dsize == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(item.DataPtr as *const u8, dsize) }.to_vec() + }; + ext_items.push(item); + ext_bufs.push(buf); + } + } + + RawEtwEvent { + header, + user_data, + ext_items, + ext_bufs, + } +} + +/// Reconstruct an [`EVENT_RECORD`] over the owned buffers and TDH-decode it. +/// Runs on the consumer thread (off the real-time pump). +#[allow(clippy::field_reassign_with_default)] +fn decode_raw(raw: &mut RawEtwEvent) -> Option { + // Re-point each extended-data item at our owned copy (TraceLogging schema + // lives here, so TDH must be able to read it). + for (item, buf) in raw.ext_items.iter_mut().zip(raw.ext_bufs.iter()) { + item.DataPtr = if buf.is_empty() { + 0 + } else { + buf.as_ptr() as u64 + }; + } + + let mut rec = EVENT_RECORD::default(); + rec.EventHeader = raw.header; + rec.UserDataLength = u16::try_from(raw.user_data.len()).unwrap_or(u16::MAX); + rec.UserData = if raw.user_data.is_empty() { + std::ptr::null_mut() + } else { + raw.user_data.as_ptr() as *mut c_void + }; + rec.ExtendedDataCount = u16::try_from(raw.ext_items.len()).unwrap_or(u16::MAX); + rec.ExtendedData = if raw.ext_items.is_empty() { + std::ptr::null_mut() + } else { + raw.ext_items.as_mut_ptr() + }; + + decode_event(std::ptr::addr_of_mut!(rec)) +} + +// --------------------------------------------------------------------------- +// Event decoding (TDH) +// --------------------------------------------------------------------------- + +/// Decode a raw event record into a neutral [`DecodedEtwEvent`] via TDH. +/// Returns `None` only when TDH decoding fails entirely. +fn decode_event(event_record: *mut EVENT_RECORD) -> Option { + let mut buf_size: u32 = 0; + let status = unsafe { TdhGetEventInformation(event_record, None, None, &mut buf_size) }; + if status != ERROR_INSUFFICIENT_BUFFER { + 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_name_offset = unsafe { info.Anonymous1.EventNameOffset }; + let event_name = wide_str_at(&buffer, event_name_offset) + .or_else(|| wide_str_at(&buffer, info.TaskNameOffset)) + .filter(|s| !s.is_empty()); + + let header = unsafe { &(*event_record).EventHeader }; + let props = decode_properties(&buffer, info, event_record); + + Some(DecodedEtwEvent { + provider: header.ProviderId, + event_id: header.EventDescriptor.Id, + level: header.EventDescriptor.Level, + opcode: header.EventDescriptor.Opcode, + process_id: header.ProcessId, + activity_id: header.ActivityId, + event_name, + props, + }) +} + +fn decode_properties( + info_buf: &[u8], + info: &TRACE_EVENT_INFO, + event_record: *mut EVENT_RECORD, +) -> Vec<(String, String)> { + 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 { + 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}")); + + // PropertyStruct flag: the header holds no data, but its child members + // occupy space in the user-data buffer, so decode+skip each to keep + // `offset` in sync. + if prop_info.Flags.0 & 1 != 0 { + let num_members = + unsafe { prop_info.Anonymous1.structType.NumOfStructMembers } as usize; + let start_index = unsafe { prop_info.Anonymous1.structType.StructStartIndex } as usize; + + for j in 0..num_members { + let child_prop = unsafe { + let base = std::ptr::addr_of!(info.EventPropertyInfoArray) + as *const EVENT_PROPERTY_INFO; + &*base.add(start_index + j) + }; + let child_in_type = unsafe { child_prop.Anonymous1.nonStructType.InType }; + let child_length = unsafe { child_prop.Anonymous3.length } as usize; + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + let (_, consumed) = + format_property_value(child_in_type, child_length, data_ptr, remaining); + offset += consumed; + } + + results.push((prop_name, "".to_string())); + continue; + } + + let in_type = unsafe { prop_info.Anonymous1.nonStructType.InType }; + let prop_length = unsafe { prop_info.Anonymous3.length } as usize; + + let remaining = user_data_len.saturating_sub(offset); + let data_ptr = if remaining > 0 { + unsafe { user_data.add(offset) } + } else { + std::ptr::null() + }; + + let (value_str, consumed) = + format_property_value(in_type, prop_length, data_ptr, remaining); + offset += consumed; + results.push((prop_name, value_str)); + } + + results +} + +/// Decode a single property value, returning `(rendered, bytes_consumed)`. +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; + 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]); + let consumed = (len + 1).min(max_wchars) * 2; + (format!("\"{s}\""), consumed) + } + TDH_INTYPE_ANSISTRING => { + 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 => ((unsafe { *data } as i8).to_string(), 1), + TDH_INTYPE_UINT8 if available >= 1 => ((unsafe { *data }).to_string(), 1), + TDH_INTYPE_INT16 if available >= 2 => { + (i16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_UINT16 if available >= 2 => { + (u16::from_le_bytes(read_bytes::<2>(data)).to_string(), 2) + } + TDH_INTYPE_INT32 if available >= 4 => { + (i32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_UINT32 if available >= 4 => { + (u32::from_le_bytes(read_bytes::<4>(data)).to_string(), 4) + } + TDH_INTYPE_INT64 if available >= 8 => { + (i64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_UINT64 if available >= 8 => { + (u64::from_le_bytes(read_bytes::<8>(data)).to_string(), 8) + } + TDH_INTYPE_FLOAT if available >= 4 => ( + format!("{:.4}", f32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_DOUBLE if available >= 8 => ( + format!("{:.4}", f64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_BOOLEAN if available >= 4 => ( + (i32::from_le_bytes(read_bytes::<4>(data)) != 0).to_string(), + 4, + ), + TDH_INTYPE_GUID if available >= 16 => { + let b = unsafe { std::slice::from_raw_parts(data, 16) }; + let d1 = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); + let d2 = u16::from_le_bytes([b[4], b[5]]); + let d3 = u16::from_le_bytes([b[6], b[7]]); + let s = format!( + "{{{d1:08x}-{d2:04x}-{d3:04x}-{:02x}{:02x}-\ + {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15] + ); + (s, 16) + } + TDH_INTYPE_HEXINT32 if available >= 4 => ( + format!("0x{:08X}", u32::from_le_bytes(read_bytes::<4>(data))), + 4, + ), + TDH_INTYPE_HEXINT64 if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_POINTER if available >= 8 => ( + format!("0x{:016X}", u64::from_le_bytes(read_bytes::<8>(data))), + 8, + ), + TDH_INTYPE_FILETIME if available >= 8 => ( + format!( + "FILETIME(0x{:016X})", + u64::from_le_bytes(read_bytes::<8>(data)) + ), + 8, + ), + _ => { + let len = if declared_length > 0 { + declared_length.min(available) + } else { + available.min(32) + }; + let bytes = unsafe { std::slice::from_raw_parts(data, len) }; + let hex: String = bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "); + (hex, len) + } + } +} + +// --------------------------------------------------------------------------- +// Attribution: MXC ETW event → OpenShell sandbox_id +// --------------------------------------------------------------------------- + +/// Runtime index that maps MXC's uneven ETW correlators back to an OpenShell +/// `sandbox_id`. Shared (`Arc>`) between the driver (which seeds +/// `pid → sandbox_id` as it spawns wxc-exec) and the ETW consumer thread. +/// +/// Attribution chain (grounded in the live `Sandboxing` capture): +/// - **pid anchor** — the wxc-exec pid we spawn is unique and driver-owned; it +/// emits `CreateProcessInSandbox`, which also carries `identity` + CV. +/// - from there we learn `identity → sandbox_id` and (`SandboxEngineCreate`) +/// `activity_id → sandbox_id`, so the payload-keyless `SandboxConfig` +/// (no identity/CV) resolves via the ETW `ActivityId` it shares. +/// - `commandLine` and a per-pid "last resolved" value are fallbacks. +#[derive(Default)] +pub(crate) struct AttributionIndex { + by_pid: HashMap, + by_identity: HashMap, + by_activity: HashMap, + by_cv: HashMap, + by_cmd: HashMap, + last_pid_sid: HashMap, + names: HashMap, + /// Sandboxes for which a lifecycle [6002] row has already been emitted, so + /// the two redundant create events don't double-count. + lifecycle_emitted: std::collections::HashSet, +} + +impl AttributionIndex { + pub fn new() -> Self { + Self::default() + } + + /// Register a launched sandbox. `wxc_pid` (the process we spawned) is the + /// collision-proof anchor; `command_line` is a fallback matcher. + pub fn register_launch( + &mut self, + sandbox_id: &str, + sandbox_name: &str, + wxc_pid: u32, + command_line: &str, + ) { + self.by_pid.insert(wxc_pid, sandbox_id.to_string()); + if !command_line.is_empty() { + self.by_cmd + .insert(command_line.to_string(), sandbox_id.to_string()); + } + self.names + .insert(sandbox_id.to_string(), sandbox_name.to_string()); + } + + /// Drop all keys for a finished sandbox to bound memory. + pub fn forget(&mut self, sandbox_id: &str) { + self.by_pid.retain(|_, v| v != sandbox_id); + self.by_identity.retain(|_, v| v != sandbox_id); + self.by_activity.retain(|_, v| v != sandbox_id); + self.by_cv.retain(|_, v| v != sandbox_id); + self.by_cmd.retain(|_, v| v != sandbox_id); + self.last_pid_sid.retain(|_, v| v != sandbox_id); + self.names.remove(sandbox_id); + self.lifecycle_emitted.remove(sandbox_id); + } + + /// Returns `true` the first time a lifecycle row should be emitted for this + /// sandbox. MXC emits two redundant create events (`SandboxEngineCreate` and + /// `SandboxCreateWithPolicyEnforcement`) and ETW drops them interchangeably + /// under load, so we anchor on whichever arrives first and dedupe here. + fn take_lifecycle_once(&mut self, sandbox_id: &str) -> bool { + self.lifecycle_emitted.insert(sandbox_id.to_string()) + } + + fn name_of(&self, sandbox_id: &str) -> String { + self.names + .get(sandbox_id) + .cloned() + .unwrap_or_else(|| sandbox_id.to_string()) + } + + /// Resolve an event to a `sandbox_id` via any known key, then cross-link the + /// other keys it carries so later keyless events attribute correctly. + fn resolve(&mut self, ev: &DecodedEtwEvent) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + let cmd = ev.get_unquoted("commandLine"); + + let sid = self + .by_pid + .get(&ev.process_id) + .cloned() + .or_else(|| { + identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + }) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| cmd.as_ref().and_then(|c| self.by_cmd.get(c).cloned())) + .or_else(|| self.last_pid_sid.get(&ev.process_id).cloned())?; + + if let Some(i) = identity { + self.by_identity.entry(i).or_insert_with(|| sid.clone()); + } + if let Some(c) = cv { + self.by_cv.entry(c).or_insert_with(|| sid.clone()); + } + if let Some(a) = activity { + self.by_activity.entry(a).or_insert_with(|| sid.clone()); + } + self.last_pid_sid.insert(ev.process_id, sid.clone()); + + Some(sid) + } +} + +/// Consumer-thread entry point: attribute one decoded event and, for the mapped +/// classes, emit an OCSF row into the gateway trail. Unmapped/unresolved events +/// are debug-logged (checkpoint-2 behaviour) so nothing is silently dropped. +fn process_event(index: &Mutex, ev: DecodedEtwEvent) { + // Activity STOP is the empty twin of START — never a distinct OCSF row. + if ev.opcode == OPCODE_STOP { + return; + } + + let (sandbox_id, sandbox_name) = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match idx.resolve(&ev) { + Some(sid) => { + let name = idx.name_of(&sid); + (sid, name) + } + None => { + drop(idx); + tracing::debug!(target: "mxc_etw", pid = ev.process_id, "unattributed {}", ev.summary()); + return; + } + } + }; + + // STOP twins are already filtered above, so activity events reaching here + // are STARTs. + match ev.event_name.as_deref().unwrap_or("") { + // Lifecycle [6002]: MXC emits two create events per sandbox — + // `SandboxEngineCreate` and `SandboxCreateWithPolicyEnforcement` — and + // ETW drops them interchangeably under buffer pressure (observed: one run + // keeps the former, the next keeps the latter). Anchor on whichever + // arrives first and dedupe so the row is emitted exactly once. + "SandboxEngineCreate" | "SandboxCreateWithPolicyEnforcement" + if ev.opcode == OPCODE_START => + { + let first = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take_lifecycle_once(&sandbox_id); + if first { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_lifecycle_create(&ctx, &sandbox_name)); + } + } + // Process [1007]: `CreateProcessInSandbox` carries the real agent command + // line + working directory. The activity fires once empty (probe) and + // once with the command — only emit for the populated one. + "CreateProcessInSandbox" if ev.opcode == OPCODE_START => { + if let Some(cmd) = ev.get_unquoted("commandLine") { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_process_launch(&ctx, &ev, &cmd)); + } else { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } + // Config [5019]: several distinct config/hardening state changes. Each is + // a genuine audit-worthy config event; `SandboxConfig` is the richest but + // drops intermittently, so the reliably-captured hardening events + // (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`) guarantee + // coverage. + "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); + } + // Finding [2004]: MXC surfaces WIL error/fallback activities during + // sandbox setup. Captured as informational (non-alert) findings so the + // audit trail records setup anomalies without crying wolf. + "ActivityError" | "FallbackError" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_finding(&ctx, &ev)); + } + _ => { + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); + } + } +} + +// --------------------------------------------------------------------------- +// OCSF mappers (checkpoint 3 subset: LIFECYCLE + CONFIG) +// --------------------------------------------------------------------------- + +/// `SandboxCreateWithPolicyEnforcement` (START) → Application Lifecycle [6002]. +fn map_lifecycle_create(ctx: &SandboxContext, sandbox_name: &str) -> OcsfEvent { + AppLifecycleBuilder::new(ctx) + .activity(ActivityId::Reset) // lifecycle label = "Start" + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(format!( + "MXC sandbox '{sandbox_name}' created with policy enforcement" + )) + .build() +} + +/// A sandbox config/hardening ETW event → Device Config State Change [5019]. +/// +/// Handles both `SandboxConfig` (full posture snapshot) and +/// `Win32kLockdownApplied` (agentic win32k lockdown): whichever config-ish +/// fields the event carries ride along as `unmapped`, and `security_level` +/// reflects any hardening signal present. +fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let flag = |k: &str| ev.get(k).map(|v| v == "1").unwrap_or(false); + let nonzero = |k: &str| ev.get(k).map(|v| v != "0").unwrap_or(false); + let hardened = flag("useLeastPrivilege") || flag("useAppContainer") || nonzero("agenticFlags"); + let security_level = if hardened { + SecurityLevelId::Secure + } else { + SecurityLevelId::Unknown + }; + + let message = match ev.event_name.as_deref().unwrap_or("") { + "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied", + "ApplyUILimits" => "MXC sandbox UI restrictions applied", + "EnforceOsPolicy" => "MXC sandbox OS policy enforced", + _ => "MXC sandbox OS policy configured", + }; + + let mut builder = ConfigStateChangeBuilder::new(ctx) + .state(StateId::Enabled, "configured") + .security_level(security_level) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .message(message); + + // Superset of config-ish fields across both event shapes; only present + // fields are attached. + for key in [ + "useAppContainer", + "integrityMode", + "integrityLevel", + "uiRestrictions", + "useLeastPrivilege", + "readWritePathsCount", + "readOnlyPathsCount", + "capabilities", + "agenticFlags", + "processId", + ] { + if let Some(v) = ev.get(key) { + builder = builder.unmapped(key, v.trim_matches('"').to_string()); + } + } + + builder.build() +} + +/// `CreateProcessInSandbox` (populated) → Process Activity [1007] "Launch". +fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str) -> OcsfEvent { + // The created process's own pid isn't in this event (it appears later in + // `ProcessLaunched`); the emitting pid is the sandbox host (wxc-exec). + let proc = Process::new(&exe_name(cmd_line), 0).with_cmd_line(cmd_line); + let cwd = ev.get_unquoted("currentDirectory").unwrap_or_default(); + let cwd_suffix = if cwd.is_empty() { + String::new() + } else { + format!(" (cwd: {cwd})") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(proc) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!( + "MXC sandbox launched process: {}{cwd_suffix}", + truncate(cmd_line, 160) + )) + .build() +} + +/// `ActivityError` / `FallbackError` → Detection Finding [2004] (informational). +fn map_finding(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let kind = ev.event_name.as_deref().unwrap_or("SandboxError"); + let uid = ev + .cv_base() + .map(|cv| format!("{kind}:{cv}")) + .unwrap_or_else(|| format!("{kind}:{}", ev.process_id)); + DetectionFindingBuilder::new(ctx) + .activity(ActivityId::Open) // finding label = "Create" + .severity(SeverityId::Informational) + .is_alert(false) + .finding_info( + FindingInfo::new(&uid, &format!("MXC sandbox {kind}")) + .with_desc("MXC emitted a WIL error/fallback activity during sandbox setup."), + ) + .message(format!("MXC reported {kind} during sandbox setup")) + .build() +} + +/// Best-effort executable name from a command line: first whitespace-delimited +/// token, stripped of any directory prefix and surrounding quotes. +fn exe_name(cmd_line: &str) -> String { + let first = cmd_line + .trim() + .split_whitespace() + .next() + .unwrap_or("process") + .trim_matches('"'); + first + .rsplit(['\\', '/']) + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("process") + .to_string() +} + +/// Truncate at a char boundary with an ellipsis (keeps shorthand tidy). +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +// --------------------------------------------------------------------------- +// OCSF emit helpers +// --------------------------------------------------------------------------- + +/// Emit an OCSF event so it lands in BOTH gateway output planes from one +/// tracing event: +/// - the **routing bus** (`TracingLogBus`) picks up the `sandbox_id` + `message` +/// fields → stdout shorthand + per-sandbox gRPC stream, and +/// - the **JSONL audit layer** (`OcsfJsonlLayer`, installed in +/// `openshell-server`'s subscriber) picks up the full structured `OcsfEvent` +/// from the thread-local bridge → durable `openshell-ocsf..log`. +/// +/// Before cp6 this fired a bare `tracing::info!` that never populated the +/// bridge, so the structured event was silently dropped and no JSONL was +/// written. `emit_ocsf_event_routed` does both jobs from a single dispatch. +fn emit_ocsf(sandbox_id: &str, event: OcsfEvent) { + openshell_ocsf::emit_ocsf_event_routed(sandbox_id, event); +} + +/// The gateway host's machine name, resolved once. This becomes `device.hostname` +/// in every emitted OCSF event, so the audit trail attributes activity to the +/// real box (e.g. `7F203-MXC-001`) rather than a static placeholder. `COMPUTERNAME` +/// is always set on Windows; we fall back to a sentinel only if it is somehow empty. +fn gateway_hostname() -> &'static str { + static HOSTNAME: std::sync::OnceLock = std::sync::OnceLock::new(); + HOSTNAME.get_or_init(|| { + std::env::var("COMPUTERNAME") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "openshell-gateway".to_string()) + }) +} + +/// Build a per-event OCSF context (not the process-wide `ctx()` singleton, since +/// one gateway process hosts many sandboxes — wrinkle #1). +fn etw_ctx(sandbox_id: &str, sandbox_name: &str) -> SandboxContext { + SandboxContext { + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + container_image: "mxc/appcontainer".to_string(), + hostname: gateway_hostname().to_string(), + product_version: env!("CARGO_PKG_VERSION").to_string(), + proxy_ip: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + proxy_port: 0, + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Stable string key for an ETW `ActivityId` GUID, or `None` for the all-zero +/// GUID (which means "no activity" and must never be used as a correlation key). +fn guid_key(g: &GUID) -> Option { + if g.data1 == 0 && g.data2 == 0 && g.data3 == 0 && g.data4 == [0u8; 8] { + return None; + } + let tail: String = g.data4.iter().map(|b| format!("{b:02x}")).collect(); + Some(format!( + "{:08x}-{:04x}-{:04x}-{tail}", + g.data1, g.data2, g.data3 + )) +} + +fn read_bytes(ptr: *const u8) -> [u8; N] { + let mut out = [0u8; N]; + unsafe { + std::ptr::copy_nonoverlapping(ptr, out.as_mut_ptr(), N); + } + out +} + +fn wide_str_at(buf: &[u8], offset: u32) -> Option { + let off = offset as usize; + if off == 0 || off >= buf.len() { + return None; + } + + let remaining = &buf[off..]; + let max_wchars = remaining.len() / 2; + if max_wchars == 0 { + return None; + } + + let wchars = + unsafe { std::slice::from_raw_parts(remaining.as_ptr().cast::(), max_wchars) }; + let len = wchars.iter().position(|&c| c == 0).unwrap_or(max_wchars); + if len == 0 { + return None; + } + + Some(String::from_utf16_lossy(&wchars[..len])) +} diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index dc251649f8..4b9d328e9f 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -27,6 +27,11 @@ mod policy; // crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. #[cfg(target_os = "windows")] mod policy_map; +// Real-time ETW → OCSF audit consumer (Plane A). Consumes the OS Sandboxing +// provider MXC drives and emits OCSF through the gateway's tracing sink. +// Windows-only. +#[cfg(target_os = "windows")] +mod etw_consumer; #[cfg(target_os = "windows")] pub use driver::{MxcBackend, MxcComputeBackend, MxcComputeConfig}; diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index e63b2be88f..415ade2f7d 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -222,10 +222,11 @@ impl SandboxContext { } } - /// Build the OCSF `Device` object. + /// Build the OCSF `Device` object, stamped with the host OS this build runs + /// on (Linux for the in-sandbox supervisor, Windows for the MXC gateway). #[must_use] pub fn device(&self) -> Device { - Device::linux(&self.hostname) + Device::for_current_os(&self.hostname) } /// Build the `proxy_endpoint` object for the Network Proxy profile. diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 345ea57175..2101beffee 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -64,5 +64,6 @@ pub use builders::{ // --- Tracing layers --- pub use tracing_layers::{ - OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clone_current_event, emit_ocsf_event, + OCSF_TARGET, OcsfJsonlLayer, OcsfShorthandLayer, clear_current_event, clone_current_event, + emit_ocsf_event, emit_ocsf_event_routed, set_current_event, }; diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index 4c42fb4a1f..bb8b3b57dc 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -34,6 +34,34 @@ impl Device { }), } } + + /// Create a Windows device with the given hostname. + #[must_use] + pub fn windows(hostname: &str) -> Self { + Self { + hostname: hostname.to_string(), + os: Some(OsInfo { + name: "Windows".to_string(), + }), + } + } + + /// Create a device stamped with the OS this build is running on. + /// + /// The gateway (Windows) and the Linux supervisor emit through the same + /// builders; the `device.os.name` should reflect the host each runs on — + /// an OS-appropriate difference, not a divergence. + #[must_use] + pub fn for_current_os(hostname: &str) -> Self { + #[cfg(target_os = "windows")] + { + Self::windows(hostname) + } + #[cfg(not(target_os = "windows"))] + { + Self::linux(hostname) + } + } } #[cfg(test)] diff --git a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs index c07cd64b53..58f5f554b0 100644 --- a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs +++ b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs @@ -36,20 +36,54 @@ pub fn clone_current_event() -> Option { /// Both layers receive the event — `clone_current_event()` is non-consuming. pub fn emit_ocsf_event(event: OcsfEvent) { // Store the event in thread-local so layers can access it - CURRENT_EVENT.with(|cell| { - *cell.borrow_mut() = Some(event); - }); + set_current_event(event); // Emit a tracing event with the `ocsf` target. // The layers detect this target and clone the OcsfEvent from thread-local. tracing::info!(target: "ocsf", "ocsf_event"); // Clear the thread-local after dispatch completes. + clear_current_event(); +} + +/// Store an `OcsfEvent` in the thread-local bridge so OCSF layers +/// (`OcsfJsonlLayer` / `OcsfShorthandLayer`) can `clone_current_event()` it +/// during tracing dispatch. Pair with [`clear_current_event`] after the emit. +/// +/// Exposed so callers that need to attach extra tracing fields to the *same* +/// event (e.g. the gateway's per-sandbox `sandbox_id` routing field — see +/// [`emit_ocsf_event_routed`]) can drive the bridge directly. +pub fn set_current_event(event: OcsfEvent) { + CURRENT_EVENT.with(|cell| { + *cell.borrow_mut() = Some(event); + }); +} + +/// Clear the thread-local bridge slot. Call after the `ocsf`-target tracing +/// event has been dispatched so it does not leak into the next emit. +pub fn clear_current_event() { CURRENT_EVENT.with(|cell| { cell.borrow_mut().take(); }); } +/// Emit an `OcsfEvent` that is BOTH picked up by the structured OCSF layers +/// (via the thread-local bridge → `OcsfJsonlLayer` writes full JSON) AND +/// routed by the gateway's `TracingLogBus` (via the `sandbox_id` + `message` +/// tracing fields → per-sandbox stream / stdout shorthand). +/// +/// This is the gateway/multi-sandbox counterpart of [`emit_ocsf_event`]: the +/// Linux in-sandbox supervisor uses the process-wide `ctx()` singleton and the +/// bare `emit_ocsf_event`, but the gateway hosts many sandboxes, so it stamps a +/// per-event `sandbox_id` field here instead. One tracing event feeds both the +/// JSONL audit file and the routing bus. +pub fn emit_ocsf_event_routed(sandbox_id: &str, event: OcsfEvent) { + let message = event.format_shorthand(); + set_current_event(event); + tracing::info!(target: "ocsf", sandbox_id = %sandbox_id, message = %message); + clear_current_event(); +} + /// Convenience macro for emitting an `OcsfEvent`. /// /// ```ignore @@ -129,4 +163,71 @@ mod tests { // Should be empty now assert!(clone_current_event().is_none()); } + + /// A `Write` sink that appends into a shared buffer we can inspect. + #[derive(Clone)] + struct SharedWriter(std::sync::Arc>>); + + impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + // cp6: the gateway routed emit must (a) drive the JSONL layer with the FULL + // structured event (parity with the Linux `ocsf_emit!` path) and (b) leave + // no residue in the thread-local afterward. + #[test] + fn test_routed_emit_writes_full_json_and_clears() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + + tracing::subscriber::with_default(subscriber, || { + emit_ocsf_event_routed("sb-parity-1", test_event()); + }); + + let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + // Exactly one JSONL line, and it is valid full OCSF JSON (not shorthand). + assert_eq!(out.matches('\n').count(), 1, "one JSONL line expected"); + let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); + assert_eq!(parsed["class_uid"], 0); + assert!(parsed.get("metadata").is_some()); + + // Thread-local must be clear after the routed emit (no bleed). + assert!(clone_current_event().is_none()); + } + + // cp6 parity: the routed path and the bare Linux path serialize the SAME + // structured event identically — routing fields don't alter the JSON body. + #[test] + fn test_routed_and_bare_paths_emit_equivalent_json() { + use crate::tracing_layers::OcsfJsonlLayer; + use tracing_subscriber::prelude::*; + + fn capture(f: impl FnOnce()) -> String { + let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let layer = OcsfJsonlLayer::new(SharedWriter(buf.clone())); + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, f); + String::from_utf8(buf.lock().unwrap().clone()).unwrap() + } + + let bare = capture(|| emit_ocsf_event(test_event())); + let routed = capture(|| emit_ocsf_event_routed("sb-1", test_event())); + + let bare_json: serde_json::Value = serde_json::from_str(bare.trim()).unwrap(); + let routed_json: serde_json::Value = serde_json::from_str(routed.trim()).unwrap(); + assert_eq!( + bare_json, routed_json, + "routed emit must match the bare path JSON" + ); + } } diff --git a/crates/openshell-ocsf/src/tracing_layers/mod.rs b/crates/openshell-ocsf/src/tracing_layers/mod.rs index c8e5d9f2e4..b57ba1364a 100644 --- a/crates/openshell-ocsf/src/tracing_layers/mod.rs +++ b/crates/openshell-ocsf/src/tracing_layers/mod.rs @@ -11,6 +11,9 @@ pub(crate) mod event_bridge; mod jsonl_layer; mod shorthand_layer; -pub use event_bridge::{OCSF_TARGET, clone_current_event, emit_ocsf_event}; +pub use event_bridge::{ + OCSF_TARGET, clear_current_event, clone_current_event, emit_ocsf_event, emit_ocsf_event_routed, + set_current_event, +}; pub use jsonl_layer::OcsfJsonlLayer; pub use shorthand_layer::OcsfShorthandLayer; diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index ae35fc0fbf..75e433697a 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -72,6 +72,7 @@ anyhow = { workspace = true } # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } # OpenTelemetry (OTLP trace export, opt-in via [openshell.gateway.otlp]) opentelemetry = { workspace = true } diff --git a/crates/openshell-server/src/tracing_setup.rs b/crates/openshell-server/src/tracing_setup.rs index edcf303072..4206bba856 100644 --- a/crates/openshell-server/src/tracing_setup.rs +++ b/crates/openshell-server/src/tracing_setup.rs @@ -7,6 +7,7 @@ //! `OpenShell` product telemetry collected for maintainers is handled by //! [`crate::telemetry`]. +use openshell_ocsf::OcsfJsonlLayer; use opentelemetry_sdk::trace::SdkTracerProvider; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -168,11 +169,13 @@ pub fn install( .map(|config| config.endpoint.as_str()); let (driver_tracer_provider, driver_setup_error) = in_process_driver_provider(selected_driver, driver_endpoint, gateway.name()); + let (jsonl_layer, jsonl_dir) = build_ocsf_jsonl_layer(); tracing_subscriber::registry() .with(env_filter) .with(tracing_subscriber::fmt::layer()) .with(tracing_log_bus.layer()) + .with(jsonl_layer) .with(tracer_provider.as_ref().map(|provider| { crate::otel_tracing::layer_excluding_driver( provider, @@ -185,6 +188,18 @@ pub fn install( )) .init(); + match jsonl_dir { + Some(dir) => tracing::info!( + target: "openshell_server", + ocsf_jsonl_dir = %dir.display(), + "OCSF JSONL audit log enabled (openshell-ocsf..log, daily rotation, keep 3)" + ), + None => tracing::debug!( + target: "openshell_server", + "OCSF JSONL audit log disabled" + ), + } + ( TracingHandle { tracer_provider, @@ -194,6 +209,84 @@ pub fn install( ) } +/// Build the OCSF JSONL audit layer for the gateway, plus the directory it +/// writes into (for a one-line startup log). Returns `(None, None)` when +/// disabled via `OPENSHELL_OCSF_JSON` or when the target directory/appender +/// cannot be opened. +/// +/// The appender is *synchronous* (not wrapped in `tracing_appender::non_blocking`) +/// so each event is written straight through to the OS on emit. This trades a +/// little throughput for durability: unlike the sandbox supervisor (which flushes +/// its non-blocking guard on graceful shutdown), the gateway's ETW capture path +/// can be force-killed by the harness, and we do not want to lose the tail of the +/// audit trail. +fn build_ocsf_jsonl_layer() -> ( + Option>, + Option, +) { + let disabled = std::env::var("OPENSHELL_OCSF_JSON") + .map(|v| { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ) + }) + .unwrap_or(false); + if disabled { + return (None, None); + } + + let dir = ocsf_log_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + eprintln!( + "openshell: could not create OCSF JSONL log dir {}: {e}", + dir.display() + ); + return (None, None); + } + + match tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix("openshell-ocsf") + .filename_suffix("log") + .max_log_files(3) + .build(&dir) + { + Ok(roller) => (Some(OcsfJsonlLayer::new(roller)), Some(dir)), + Err(e) => { + eprintln!( + "openshell: could not open OCSF JSONL appender in {}: {e}", + dir.display() + ); + (None, None) + } + } +} + +/// Resolve the directory for the OCSF JSONL audit file. +/// +/// Precedence: `OPENSHELL_OCSF_LOG_DIR` (harness / operator override) → +/// `%PROGRAMDATA%\OpenShell\logs` on Windows → `/var/log` elsewhere. +fn ocsf_log_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("OPENSHELL_OCSF_LOG_DIR") { + let trimmed = dir.trim(); + if !trimmed.is_empty() { + return std::path::PathBuf::from(trimmed); + } + } + #[cfg(target_os = "windows")] + { + if let Ok(pd) = std::env::var("ProgramData") { + return std::path::PathBuf::from(pd).join("OpenShell").join("logs"); + } + std::env::temp_dir().join("openshell").join("logs") + } + #[cfg(not(target_os = "windows"))] + { + std::path::PathBuf::from("/var/log") + } +} + #[cfg(test)] mod tests { use super::*; From 75f667a07515a0d52984ad4e27b1697f1cf0e0b2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Thu, 9 Jul 2026 13:47:25 -0600 Subject: [PATCH 02/10] feat(mxc): map remaining Sandboxing ETW events to OCSF Close the last three ETW->OCSF gaps so the audit trail covers the full set of events the Sandboxing provider emits (12/12): - ProcessLaunched -> Process Activity [1007] "Launch" (confirmed start; carries the real processId/threadId, the twin of CreateProcessInSandbox which only has the request + command line). - SandboxProxyConfigured -> Device Config State Change [5019] (the one network-plane setup event; surfaces proxyPort, "no proxy" when 0). - SandboxConsoleReferencePlumbed -> Device Config State Change [5019] (console-handle plumbing). map_config_state now handles the full config/hardening/setup family and carries proxyPort/hasConsoleReference/creationFlags as unmapped fields. Verified on 7F203-MXC-001: 11/12 event types emit OCSF without a proxy (SandboxProxyConfigured requires proxy config to fire). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 6db6009a72..55a0897e09 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -974,12 +974,24 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); } } - // Config [5019]: several distinct config/hardening state changes. Each is - // a genuine audit-worthy config event; `SandboxConfig` is the richest but + // Process [1007]: `ProcessLaunched` is the confirmation twin of + // `CreateProcessInSandbox` — it carries the *actual* `processId`/`threadId` + // of the started in-sandbox process (the create event only has the request + + // command line). We emit it as a distinct PROC row so the trail records both + // the launch request (with cmd line) and the confirmed start (with real pid). + "ProcessLaunched" => { + let ctx = etw_ctx(&sandbox_id, &sandbox_name); + emit_ocsf(&sandbox_id, map_process_started(&ctx, &ev)); + } + // Config [5019]: several distinct config/hardening/setup state changes. Each + // is a genuine audit-worthy config event; `SandboxConfig` is the richest but // drops intermittently, so the reliably-captured hardening events // (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`) guarantee - // coverage. - "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" => { + // coverage. `SandboxProxyConfigured` (network/proxy setup — the one + // network-plane event the provider emits) and `SandboxConsoleReferencePlumbed` + // (console-handle plumbing) are additional per-sandbox setup state changes. + "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" + | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { let ctx = etw_ctx(&sandbox_id, &sandbox_name); emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); } @@ -1012,12 +1024,15 @@ fn map_lifecycle_create(ctx: &SandboxContext, sandbox_name: &str) -> OcsfEvent { .build() } -/// A sandbox config/hardening ETW event → Device Config State Change [5019]. +/// A sandbox config/hardening/setup ETW event → Device Config State Change [5019]. /// -/// Handles both `SandboxConfig` (full posture snapshot) and -/// `Win32kLockdownApplied` (agentic win32k lockdown): whichever config-ish -/// fields the event carries ride along as `unmapped`, and `security_level` -/// reflects any hardening signal present. +/// Handles the full family of per-sandbox config state changes the Sandboxing +/// provider emits: `SandboxConfig` (full posture snapshot), the hardening events +/// (`Win32kLockdownApplied`, `ApplyUILimits`, `EnforceOsPolicy`), +/// `SandboxProxyConfigured` (network/proxy setup) and +/// `SandboxConsoleReferencePlumbed` (console-handle plumbing). Whichever +/// config-ish fields the event carries ride along as `unmapped`, and +/// `security_level` reflects any hardening signal present. fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { let flag = |k: &str| ev.get(k).map(|v| v == "1").unwrap_or(false); let nonzero = |k: &str| ev.get(k).map(|v| v != "0").unwrap_or(false); @@ -1029,10 +1044,17 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { }; let message = match ev.event_name.as_deref().unwrap_or("") { - "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied", - "ApplyUILimits" => "MXC sandbox UI restrictions applied", - "EnforceOsPolicy" => "MXC sandbox OS policy enforced", - _ => "MXC sandbox OS policy configured", + "Win32kLockdownApplied" => "MXC sandbox win32k lockdown applied".to_string(), + "ApplyUILimits" => "MXC sandbox UI restrictions applied".to_string(), + "EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(), + "SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(), + // The one network-plane event the provider emits; `proxyPort=0` means no + // proxy was configured. Surface the port so the CONFIG row is self-describing. + "SandboxProxyConfigured" => match ev.get_unquoted("proxyPort").as_deref() { + Some("0") | None => "MXC sandbox proxy configured (no proxy)".to_string(), + Some(port) => format!("MXC sandbox proxy configured (port {port})"), + }, + _ => "MXC sandbox OS policy configured".to_string(), }; let mut builder = ConfigStateChangeBuilder::new(ctx) @@ -1042,7 +1064,7 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { .status(StatusId::Success) .message(message); - // Superset of config-ish fields across both event shapes; only present + // Superset of config-ish fields across all event shapes; only present // fields are attached. for key in [ "useAppContainer", @@ -1055,6 +1077,9 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { "capabilities", "agenticFlags", "processId", + "proxyPort", + "hasConsoleReference", + "creationFlags", ] { if let Some(v) = ev.get(key) { builder = builder.unmapped(key, v.trim_matches('"').to_string()); @@ -1091,6 +1116,38 @@ fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str .build() } +/// `ProcessLaunched` → Process Activity [1007] "Launch" (confirmed start). +/// +/// Unlike `CreateProcessInSandbox` (the request, which carries the command line +/// but not the resulting pid), this event carries the real `processId`/`threadId` +/// of the process that actually started. We give the process a distinct name +/// (`sandboxed-process`) so the shorthand row is visibly the confirmed-start twin, +/// not a duplicate of the launch-request row. +fn map_process_started(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { + let pid = ev + .get("processId") + .map(|v| v.trim_matches('"')) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let tid = ev.get_unquoted("threadId").unwrap_or_default(); + let tid_suffix = if tid.is_empty() { + String::new() + } else { + format!(", tid: {tid}") + }; + ProcessActivityBuilder::new(ctx) + .activity(ActivityId::Open) // process label = "Launch" + .launch_type(LaunchTypeId::Spawn) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .process(Process::new("sandboxed-process", pid)) + .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) + .message(format!("MXC sandbox process started (pid: {pid}{tid_suffix})")) + .build() +} + /// `ActivityError` / `FallbackError` → Detection Finding [2004] (informational). fn map_finding(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { let kind = ev.event_name.as_deref().unwrap_or("SandboxError"); From 8b9c42366f4b966017ba78bfa099dcba2341db78 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Thu, 9 Jul 2026 13:59:25 -0600 Subject: [PATCH 03/10] fix(mxc): seed ETW attribution under registry lock + Device tests Address CodeRabbit review on !31: - Prevent stale ETW attribution on a delete/launch race: register the wxc-exec pid while holding the registry lock, and bail if the sandbox entry is already gone. Previously the attribution key could be seeded after `delete` had removed the sandbox, leaving a stale key that could misroute later Sandboxing ETW events to a dead sandbox_id. Lock order (registry -> attribution) matches the delete path, so no deadlock. - Add unit tests for the new Device::windows and Device::for_current_os constructors to harden Windows/Linux OCSF device parity. Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/src/driver.rs | 52 +++++++++++-------- .../openshell-driver-mxc/src/etw_consumer.rs | 12 +++-- crates/openshell-ocsf/src/objects/device.rs | 19 +++++++ 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 97234ba792..b5334fda70 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -766,16 +766,6 @@ async fn run_lifecycle( }; info!(sandbox = %sandbox_name, command = %command_line, backend = ?config.backend, "MXC agent launched"); - // Seed ETW attribution: the wxc-exec pid we just spawned is the - // collision-proof anchor that ties the `Sandboxing` provider's events back - // to this `sandbox_id` (command line is a fallback matcher). No-op unless - // the ETW consumer is running. - if let Some(pid) = child.id() { - if let Ok(mut idx) = attribution.lock() { - idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); - } - } - let ready_sandbox = make_sandbox_with_condition( &sandbox, &DriverCondition { @@ -793,19 +783,37 @@ async fn run_lifecycle( // process exit. Holding the registry lock while spawning prevents a // completed child from being overwritten with AgentRunning. let mut registry_guard = registry.lock().await; - if let Some(entry) = registry_guard.get_mut(&sandbox_id) { - entry.sandbox = ready_sandbox.clone(); - entry.phase_state = PhaseState::Running; - entry.monitor_cancel = Some(cancel_tx); - entry.monitor_task = Some(tokio::spawn(monitor_exec( - registry.clone(), - watch_tx.clone(), - sandbox.clone(), - sandbox_id.clone(), - cancel_rx, - child, - ))); + let Some(entry) = registry_guard.get_mut(&sandbox_id) else { + // The sandbox was deleted between agent launch and readiness. Bail + // without seeding ETW attribution (a stale key would misroute later + // events to a dead sandbox), without reporting Ready, and without + // spawning the exec monitor. `delete` already tore down the process. + return; + }; + + // Seed ETW attribution while holding the registry lock so a concurrent + // `delete` cannot remove the sandbox after we register (which would leave + // a stale key). The `wxc-exec` pid we just spawned is the collision-proof + // anchor that ties the `Sandboxing` provider's events back to this + // `sandbox_id` (command line is a fallback matcher). No-op unless the ETW + // consumer is running. + if let Some(pid) = child.id() { + if let Ok(mut idx) = attribution.lock() { + idx.register_launch(&sandbox_id, &sandbox_name, pid, &command_line); + } } + + entry.sandbox = ready_sandbox.clone(); + entry.phase_state = PhaseState::Running; + entry.monitor_cancel = Some(cancel_tx); + entry.monitor_task = Some(tokio::spawn(monitor_exec( + registry.clone(), + watch_tx.clone(), + sandbox.clone(), + sandbox_id.clone(), + cancel_rx, + child, + ))); } let _ = watch_tx.send(sandbox_event(ready_sandbox)); } diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 55a0897e09..68a20d416a 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -990,8 +990,12 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // coverage. `SandboxProxyConfigured` (network/proxy setup — the one // network-plane event the provider emits) and `SandboxConsoleReferencePlumbed` // (console-handle plumbing) are additional per-sandbox setup state changes. - "SandboxConfig" | "Win32kLockdownApplied" | "ApplyUILimits" | "EnforceOsPolicy" - | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { + "SandboxConfig" + | "Win32kLockdownApplied" + | "ApplyUILimits" + | "EnforceOsPolicy" + | "SandboxProxyConfigured" + | "SandboxConsoleReferencePlumbed" => { let ctx = etw_ctx(&sandbox_id, &sandbox_name); emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); } @@ -1144,7 +1148,9 @@ fn map_process_started(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent .status(StatusId::Success) .process(Process::new("sandboxed-process", pid)) .actor_process(Process::new("wxc-exec", i64::from(ev.process_id))) - .message(format!("MXC sandbox process started (pid: {pid}{tid_suffix})")) + .message(format!( + "MXC sandbox process started (pid: {pid}{tid_suffix})" + )) .build() } diff --git a/crates/openshell-ocsf/src/objects/device.rs b/crates/openshell-ocsf/src/objects/device.rs index bb8b3b57dc..0f38ef446e 100644 --- a/crates/openshell-ocsf/src/objects/device.rs +++ b/crates/openshell-ocsf/src/objects/device.rs @@ -75,4 +75,23 @@ mod tests { assert_eq!(json["hostname"], "sandbox-abc123"); assert_eq!(json["os"]["name"], "Linux"); } + + #[test] + fn test_device_windows() { + let device = Device::windows("gateway-host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "gateway-host"); + assert_eq!(json["os"]["name"], "Windows"); + } + + #[test] + fn test_device_for_current_os() { + let device = Device::for_current_os("host"); + let json = serde_json::to_value(&device).unwrap(); + assert_eq!(json["hostname"], "host"); + #[cfg(target_os = "windows")] + assert_eq!(json["os"]["name"], "Windows"); + #[cfg(not(target_os = "windows"))] + assert_eq!(json["os"]["name"], "Linux"); + } } From 9467b464e1bcdc6b2b4f12ee9ed4e028a76d8b25 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:04:14 -0600 Subject: [PATCH 04/10] fix(mxc-etw): buffer+replay racing events and harden attribution keys Addresses two ETW->OCSF attribution review items (Shailendra #1, #2). #2 early-event loss: ETW delivers the sandbox create/config burst the instant wxc-exec starts, which can beat the driver's register_launch (now under the registry lock post-Ready). process_event previously dropped anything unresolved, losing the racing burst. Add a bounded, time-bounded pending buffer (PENDING_MAX=4096, PENDING_TTL=5s): unresolved events are held and replayed once attribution lands, aged-out ones dropped. Consumer switched to a timed recv_timeout(200ms) so the buffer is re-driven after each event and on a tick. Emit path factored into shared emit_resolved(). #1 attribution collisions: a Windows PID is recycled after exit and a command line is commonly identical across sandboxes. register_launch now rebinds by_pid on reuse and clears the stale last_pid_sid hint (warns if the PID still pointed at a different, leaked sandbox); command line is held in by_cmd only while unique and demoted to a new ambiguous_cmds set on a second owner, so a duplicate command refuses to resolve rather than misroute. Unit tests: buffer replay (direct + cross-link), buffer bound, PID-reuse rebind, duplicate-cmd non-resolution. Box-verified on 7F203-MXC-001 (5 sandboxes, identical cmd -> 5 isolated sandbox_ids, 50/50 OCSF/JSONL, BuffersLost=0). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 352 ++++++++++++++++-- 1 file changed, 318 insertions(+), 34 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 68a20d416a..856a79a168 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -50,11 +50,12 @@ clippy::doc_markdown )] -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::ffi::c_void; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use windows::Win32::Foundation::WIN32_ERROR; use windows::Win32::System::Diagnostics::Etw::{ @@ -229,7 +230,8 @@ impl EtwSession { self.handle = 0; } // ControlTraceW(STOP) makes ProcessTrace return → the pump thread ends and - // drops the boxed Sender → the consumer thread's `for ev in rx` ends. + // drops the boxed Sender → the consumer thread's recv loop sees + // `Disconnected`, does a final pending drain, and exits. if let Some(t) = self.pump_thread.take() { let _ = t.join(); } @@ -273,18 +275,32 @@ pub(crate) fn start_session(index: Arc>) -> Result process_event(&index, ev), - None => tracing::debug!( - target: "mxc_etw", - id = raw.header.EventDescriptor.Id, - opcode = raw.header.EventDescriptor.Opcode, - pid = raw.header.ProcessId, - "TDH decode failed for event" - ), + // + // A *timed* recv lets us also re-drive the pending buffer during a + // lull: an event that beat the driver's `register_launch` is replayed + // within one tick once attribution lands, without having to wait for + // the next ETW event (which may never arrive for a lone/last sandbox). + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(mut raw) => { + match decode_raw(&mut raw) { + Some(ev) => process_event(&index, ev), + None => tracing::debug!( + target: "mxc_etw", + id = raw.header.EventDescriptor.Id, + opcode = raw.header.EventDescriptor.Opcode, + pid = raw.header.ProcessId, + "TDH decode failed for event" + ), + } + drain_and_emit(&index); + } + Err(mpsc::RecvTimeoutError::Timeout) => drain_and_emit(&index), + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } + // Final drain on shutdown so anything still resolvable is emitted. + drain_and_emit(&index); }) .map_err(|e| { stop_session(handle); @@ -812,18 +828,49 @@ fn format_property_value( /// `activity_id → sandbox_id`, so the payload-keyless `SandboxConfig` /// (no identity/CV) resolves via the ETW `ActivityId` it shares. /// - `commandLine` and a per-pid "last resolved" value are fallbacks. +/// An ETW event that could not yet be attributed, held so it can be replayed +/// once its sandbox's attribution is seeded. +struct PendingEvent { + at: Instant, + ev: DecodedEtwEvent, +} + +/// Max number of unattributed events buffered at once (memory bound). The +/// create/config burst is ~10 events per sandbox, so this comfortably holds +/// many concurrent racing launches while still capping worst-case memory. +const PENDING_MAX: usize = 4096; + +/// How long an unattributed event is held before being given up on. The +/// driver seeds attribution within milliseconds of spawning `wxc-exec`, so a +/// few seconds is ample; anything older is almost certainly genuinely +/// unattributable (e.g. an unrelated Sandboxing-provider consumer on the box). +const PENDING_TTL: Duration = Duration::from_secs(5); + #[derive(Default)] pub(crate) struct AttributionIndex { by_pid: HashMap, by_identity: HashMap, by_activity: HashMap, by_cv: HashMap, + /// Command line → sandbox_id, but **only while that command line is unique**. + /// The instant a second sandbox registers the same command line it is moved to + /// [`Self::ambiguous_cmds`] and removed here, so an ambiguous command can never + /// misroute an event. Command line is a weak, last-resort key for exactly this + /// reason (two sandboxes commonly run the identical agent command). by_cmd: HashMap, + /// Command lines seen for more than one sandbox — never usable for resolution. + ambiguous_cmds: std::collections::HashSet, last_pid_sid: HashMap, names: HashMap, /// Sandboxes for which a lifecycle [6002] row has already been emitted, so /// the two redundant create events don't double-count. lifecycle_emitted: std::collections::HashSet, + /// Events that arrived before their sandbox's attribution was seeded. ETW + /// delivers the create/config burst the instant `wxc-exec` starts, which can + /// race the driver's `register_launch`; rather than drop those events we hold + /// them here and replay when a later registration/cross-link resolves them. + /// Bounded by [`PENDING_MAX`] and [`PENDING_TTL`]. + pending: VecDeque, } impl AttributionIndex { @@ -832,7 +879,9 @@ impl AttributionIndex { } /// Register a launched sandbox. `wxc_pid` (the process we spawned) is the - /// collision-proof anchor; `command_line` is a fallback matcher. + /// primary anchor — unique *while that process is alive* (Windows won't reuse + /// a live PID). `command_line` is only a weak fallback and is dropped the + /// moment it stops being unique (see [`Self::ambiguous_cmds`]). pub fn register_launch( &mut self, sandbox_id: &str, @@ -840,11 +889,40 @@ impl AttributionIndex { wxc_pid: u32, command_line: &str, ) { + // PID-reuse guard: if this PID still maps to a *different* sandbox, the + // prior sandbox was never `forget()`-ten (e.g. a crash skipped `delete`) + // and Windows has recycled the number. Rebind to the new owner and drop + // the stale per-PID "last resolved" hint so it can't misroute. + if let Some(prev) = self.by_pid.get(&wxc_pid) { + if prev != sandbox_id { + tracing::warn!( + target: "mxc_etw", + pid = wxc_pid, + prev = %prev, + new = %sandbox_id, + "wxc-exec PID reused before prior sandbox was forgotten; rebinding attribution" + ); + } + } self.by_pid.insert(wxc_pid, sandbox_id.to_string()); - if !command_line.is_empty() { - self.by_cmd - .insert(command_line.to_string(), sandbox_id.to_string()); + self.last_pid_sid.remove(&wxc_pid); + + // Command line is only trustworthy while unique. Promote to `by_cmd` on + // first sight; on a second, different owner, demote to ambiguous forever. + if !command_line.is_empty() && !self.ambiguous_cmds.contains(command_line) { + match self.by_cmd.get(command_line) { + Some(existing) if existing != sandbox_id => { + self.by_cmd.remove(command_line); + self.ambiguous_cmds.insert(command_line.to_string()); + } + Some(_) => {} // same owner re-registering; keep + None => { + self.by_cmd + .insert(command_line.to_string(), sandbox_id.to_string()); + } + } } + self.names .insert(sandbox_id.to_string(), sandbox_name.to_string()); } @@ -915,6 +993,57 @@ impl AttributionIndex { Some(sid) } + + /// Hold an event that didn't resolve yet, evicting expired and (if needed) + /// oldest entries first so the buffer stays bounded. + fn buffer_unresolved(&mut self, ev: DecodedEtwEvent) { + let now = Instant::now(); + while let Some(front) = self.pending.front() { + if now.duration_since(front.at) > PENDING_TTL { + let stale = self.pending.pop_front(); + if let Some(p) = stale { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + } + } else { + break; + } + } + if self.pending.len() >= PENDING_MAX { + if let Some(p) = self.pending.pop_front() { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (buffer full) {}", p.ev.summary()); + } + } + self.pending.push_back(PendingEvent { at: now, ev }); + } + + /// Re-resolve buffered events. Returns those that now attribute (removed + /// from the buffer, in arrival order, ready to emit) and drops any that have + /// aged past [`PENDING_TTL`] still unresolved. Callers emit the returned + /// events *after* releasing the index lock. + fn drain_resolved(&mut self) -> Vec<(String, String, DecodedEtwEvent)> { + if self.pending.is_empty() { + return Vec::new(); + } + let now = Instant::now(); + let drained = std::mem::take(&mut self.pending); + let mut ready = Vec::new(); + let mut keep = VecDeque::with_capacity(drained.len()); + for p in drained { + if now.duration_since(p.at) > PENDING_TTL { + tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); + continue; + } + match self.resolve(&p.ev) { + Some(sid) => { + let name = self.name_of(&sid); + ready.push((sid, name, p.ev)); + } + None => keep.push_back(p), + } + } + self.pending = keep; + ready + } } /// Consumer-thread entry point: attribute one decoded event and, for the mapped @@ -926,25 +1055,56 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { return; } - let (sandbox_id, sandbox_name) = { + let resolved = { let mut idx = index .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); match idx.resolve(&ev) { Some(sid) => { let name = idx.name_of(&sid); - (sid, name) + Some((sid, name, ev)) } None => { - drop(idx); - tracing::debug!(target: "mxc_etw", pid = ev.process_id, "unattributed {}", ev.summary()); - return; + // Not attributable yet: ETW delivers the create/config burst the + // instant `wxc-exec` starts, which can beat the driver's + // `register_launch`. Hold the event for replay instead of dropping + // it (see `drain_and_emit`). + idx.buffer_unresolved(ev); + None } } }; - // STOP twins are already filtered above, so activity events reaching here - // are STARTs. + if let Some((sandbox_id, sandbox_name, ev)) = resolved { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Re-resolve and emit any buffered events that have since become attributable. +/// Called by the consumer thread after each incoming event and on a periodic +/// tick, so a create/config burst that raced `register_launch` still lands in +/// the trail (and aged-out unresolvable events are dropped, bounded). +fn drain_and_emit(index: &Mutex) { + let ready = { + let mut idx = index + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idx.drain_resolved() + }; + for (sandbox_id, sandbox_name, ev) in ready { + emit_resolved(index, &sandbox_id, &sandbox_name, &ev); + } +} + +/// Map one attributed event to its OCSF class and emit it into the gateway trail. +fn emit_resolved( + index: &Mutex, + sandbox_id: &str, + sandbox_name: &str, + ev: &DecodedEtwEvent, +) { + // STOP twins are already filtered before buffering, so activity events + // reaching here are STARTs. match ev.event_name.as_deref().unwrap_or("") { // Lifecycle [6002]: MXC emits two create events per sandbox — // `SandboxEngineCreate` and `SandboxCreateWithPolicyEnforcement` — and @@ -957,10 +1117,10 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { let first = index .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .take_lifecycle_once(&sandbox_id); + .take_lifecycle_once(sandbox_id); if first { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_lifecycle_create(&ctx, &sandbox_name)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_lifecycle_create(&ctx, sandbox_name)); } } // Process [1007]: `CreateProcessInSandbox` carries the real agent command @@ -968,8 +1128,8 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // once with the command — only emit for the populated one. "CreateProcessInSandbox" if ev.opcode == OPCODE_START => { if let Some(cmd) = ev.get_unquoted("commandLine") { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_process_launch(&ctx, &ev, &cmd)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_launch(&ctx, ev, &cmd)); } else { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); } @@ -980,8 +1140,8 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { // command line). We emit it as a distinct PROC row so the trail records both // the launch request (with cmd line) and the confirmed start (with real pid). "ProcessLaunched" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_process_started(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_process_started(&ctx, ev)); } // Config [5019]: several distinct config/hardening/setup state changes. Each // is a genuine audit-worthy config event; `SandboxConfig` is the richest but @@ -996,15 +1156,15 @@ fn process_event(index: &Mutex, ev: DecodedEtwEvent) { | "EnforceOsPolicy" | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_config_state(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_config_state(&ctx, ev)); } // Finding [2004]: MXC surfaces WIL error/fallback activities during // sandbox setup. Captured as informational (non-alert) findings so the // audit trail records setup anomalies without crying wolf. "ActivityError" | "FallbackError" => { - let ctx = etw_ctx(&sandbox_id, &sandbox_name); - emit_ocsf(&sandbox_id, map_finding(&ctx, &ev)); + let ctx = etw_ctx(sandbox_id, sandbox_name); + emit_ocsf(sandbox_id, map_finding(&ctx, ev)); } _ => { tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); @@ -1296,3 +1456,127 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { Some(String::from_utf16_lossy(&wchars[..len])) } + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_event(pid: u32, name: &str) -> DecodedEtwEvent { + DecodedEtwEvent { + provider: GUID::from_u128(0), + event_id: 1, + level: 4, + opcode: OPCODE_START, + process_id: pid, + activity_id: GUID::from_u128(0), + event_name: Some(name.to_string()), + props: Vec::new(), + } + } + + // Shailendra #2: the create/config burst can reach the consumer before the + // driver's `register_launch` seeds attribution. An event that doesn't resolve + // must be held and replayed once attribution lands — not dropped. + #[test] + fn buffered_event_replays_after_registration() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1234, "SandboxConfig"); + + // Arrives before registration → unresolved → buffered, not dropped. + assert!(idx.resolve(&ev).is_none()); + idx.buffer_unresolved(ev); + assert!( + idx.drain_resolved().is_empty(), + "nothing to drain pre-registration" + ); + + // Driver seeds attribution for the wxc-exec pid we spawned. + idx.register_launch("sbx-1", "my-sandbox", 1234, "agent --run"); + + // The buffered event now attributes and is returned for emit, in order. + let ready = idx.drain_resolved(); + assert_eq!(ready.len(), 1); + assert_eq!(ready[0].0, "sbx-1"); + assert_eq!(ready[0].1, "my-sandbox"); + assert_eq!(ready[0].2.process_id, 1234); + + // And it's removed from the buffer (no double emit). + assert!(idx.drain_resolved().is_empty()); + } + + // Genuinely unattributable events (e.g. from unrelated Sandboxing activity) + // must never grow the buffer without bound. + #[test] + fn pending_buffer_is_bounded() { + let mut idx = AttributionIndex::new(); + for pid in 0..(PENDING_MAX as u32 + 50) { + idx.buffer_unresolved(mk_event(pid, "SandboxConfig")); + } + assert!( + idx.pending.len() <= PENDING_MAX, + "buffer exceeded PENDING_MAX" + ); + } + + // A buffered event that resolves via a cross-linked correlator (not just the + // pid) is also replayed: register one pid, then an event sharing only the + // activity id resolves after the first event cross-links it. + #[test] + fn buffered_event_replays_via_crosslink() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-9", "s9", 4321, "agent"); + + // First event carries the pid + an activity id → resolves and cross-links + // the activity id to sbx-9. + let mut anchor = mk_event(4321, "CreateProcessInSandbox"); + anchor.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&anchor).as_deref(), Some("sbx-9")); + + // A later payload-keyless event shares only the activity id (different + // pid) — it must now resolve via the cross-link. + let mut keyless = mk_event(0, "SandboxConfig"); + keyless.activity_id = GUID::from_u128(0xABCD); + assert_eq!(idx.resolve(&keyless).as_deref(), Some("sbx-9")); + } + + // Shailendra #1 (PID reuse): if a sandbox leaked (no `forget`) and Windows + // recycles its wxc-exec PID for a new sandbox, events on that PID must route + // to the *new* owner, never the dead one. + #[test] + fn pid_reuse_rebinds_to_new_sandbox() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-A", "A", 1000, "agent --a"); + let ev_a = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_a).as_deref(), Some("sbx-A")); + + // A leaks (delete never ran). PID 1000 is recycled for B. + idx.register_launch("sbx-B", "B", 1000, "agent --b"); + let ev_b = mk_event(1000, "CreateProcessInSandbox"); + assert_eq!(idx.resolve(&ev_b).as_deref(), Some("sbx-B")); + } + + // Shailendra #1 (cmd ambiguity): two sandboxes running the identical command + // line must not let that command line resolve anything (it's ambiguous); a + // unique command line still works as a fallback. + #[test] + fn duplicate_command_line_is_not_used_for_resolution() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --run"); + idx.register_launch("sbx-2", "s2", 22, "agent --run"); // same cmd → ambiguous + + // Event carrying ONLY the duplicate command line (unknown pid, no + // identity/activity) must NOT resolve — refusing beats misrouting. + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --run\"".into())); + assert!(idx.resolve(&only_cmd).is_none()); + + // A still-unique command line resolves via the fallback as before. + idx.register_launch("sbx-3", "s3", 33, "agent --unique"); + let mut uniq = mk_event(998, "SandboxConfig"); + uniq.props + .push(("commandLine".into(), "\"agent --unique\"".into())); + assert_eq!(idx.resolve(&uniq).as_deref(), Some("sbx-3")); + } +} From 5efe3cdc339dfc89ca560da2348e538a56cdbf86 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:14:53 -0600 Subject: [PATCH 05/10] docs(mxc-etw): note cmd_line is captured raw with no privacy filtering Review item #3 (Shailendra): add a PRIVACY NOTE on map_process_launch stating cmd_line is copied verbatim into OCSF process.cmd_line with no redaction, so secrets/PII on a command line land unredacted in the durable audit trail (deliberate audit-fidelity trade-off; treat the log as sensitive). Redaction is owned by an upstream privacy layer, not this path; no general audit-output PII scrubber exists today (openshell_core::secrets [CREDENTIAL] redaction is scoped to the proxy HTTP-target logging, a separate egress path). Signed-off-by: Akber Raza --- crates/openshell-driver-mxc/src/etw_consumer.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 856a79a168..d91d46f34e 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1254,6 +1254,20 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { } /// `CreateProcessInSandbox` (populated) → Process Activity [1007] "Launch". +/// +/// PRIVACY NOTE (review item #3): `cmd_line` is copied **verbatim** from MXC's +/// ETW event into the OCSF `process.cmd_line` field. This consumer performs **no +/// privacy/secret filtering** — if a caller passes credentials, tokens, or PII on +/// the command line, they will appear **unredacted** in the durable audit trail. +/// This is deliberate (audit fidelity), so the OCSF log must be treated as +/// sensitive at rest and in transit. +/// +/// Redaction is intentionally **not** done here and is owned by an upstream +/// privacy layer, not the ETW→OCSF path. Note that no general PII/secret scrubber +/// covers this field today: the only redaction that exists +/// (`openshell_core::secrets`, `${…}` → `[CREDENTIAL]`) is scoped to the network +/// proxy's HTTP-target logging, a separate egress path. If/when a general +/// audit-output PII filter lands, this field is where it must apply. fn map_process_launch(ctx: &SandboxContext, ev: &DecodedEtwEvent, cmd_line: &str) -> OcsfEvent { // The created process's own pid isn't in this event (it appears later in // `ProcessLaunched`); the emitting pid is the sandbox host (wxc-exec). From 65bb41f16e436e609cb99f687bb7266ac6c8e618 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:32:16 -0600 Subject: [PATCH 06/10] fix(mxc-etw): open ETW trace on caller thread so start_session reports real status Review item #4 (Shailendra): start_session previously returned Ok(EtwSession) as soon as the pump thread was spawned, but OpenTraceW ran later inside that thread; if it failed we still handed back a live-looking session and logged 'consumer started' (silent failure = false audit coverage). Split the two Win32 calls instead of adding a channel handshake (avoids any lost-wakeup/hang risk): the quick, synchronous OpenTraceW now runs on the caller thread (open_trace), and only the blocking ProcessTrace runs on the pump thread (run_trace). start_session returns Err if OpenTraceW fails (reclaiming the boxed Sender so the consumer disconnects, stopping the session, joining the consumer) and returns Ok/logs 'started' only once capture is genuinely open. Opened handle + LoggerName buffer + boxed Sender are carried to the pump via a Send OpenedTrace so they outlive ProcessTrace. Box-verified on 7F203-MXC-001: consumer started=True, failed-to-start=False, 50 OCSF rows / 50 JSONL, BuffersLost=0 (no regression to capture/emit). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 104 ++++++++++++++---- 1 file changed, 81 insertions(+), 23 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index d91d46f34e..da707851df 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -62,8 +62,9 @@ use windows::Win32::System::Diagnostics::Etw::{ CONTROLTRACE_HANDLE, CloseTrace, ControlTraceW, EVENT_HEADER, EVENT_HEADER_EXTENDED_DATA_ITEM, EVENT_PROPERTY_INFO, EVENT_RECORD, EVENT_TRACE_CONTROL_STOP, EVENT_TRACE_LOGFILEW, EVENT_TRACE_PROPERTIES, EVENT_TRACE_REAL_TIME_MODE, EnableTraceEx2, OpenTraceW, - PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, ProcessTrace, StartTraceW, - TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, WNODE_FLAG_TRACED_GUID, + PROCESS_TRACE_MODE_EVENT_RECORD, PROCESS_TRACE_MODE_REAL_TIME, PROCESSTRACE_HANDLE, + ProcessTrace, StartTraceW, TRACE_EVENT_INFO, TRACE_LEVEL_VERBOSE, TdhGetEventInformation, + WNODE_FLAG_TRACED_GUID, }; use windows::core::{GUID, PCWSTR, PWSTR}; @@ -247,11 +248,22 @@ impl Drop for EtwSession { } } -/// Wrapper to move a raw `Sender` pointer across the thread boundary into the -/// blocking `ProcessTrace` worker. SAFETY: the boxed `Sender` lives until the -/// worker reclaims it after `ProcessTrace` returns. -struct SendPtr(*mut mpsc::Sender); -unsafe impl Send for SendPtr {} +/// A successfully-opened real-time trace, handed to the pump thread to run the +/// blocking `ProcessTrace`. Produced by [`open_trace`] on the *caller* thread so +/// an `OpenTraceW` failure is surfaced synchronously (review #4) rather than +/// dying silently on the worker after `start_session` already returned `Ok`. +/// +/// SAFETY (`Send`): the contained raw `Sender` pointer and trace handle are only +/// ever touched by the single pump thread that takes ownership of this struct; +/// the boxed `Sender` lives until that thread reclaims it after `ProcessTrace` +/// returns, and `name` (the `LoggerName` buffer `OpenTraceW` referenced) is kept +/// alive for the whole `ProcessTrace` duration. +struct OpenedTrace { + handle: PROCESSTRACE_HANDLE, + name: Vec, + tx_ptr: *mut mpsc::Sender, +} +unsafe impl Send for OpenedTrace {} // --------------------------------------------------------------------------- // Public API @@ -307,14 +319,37 @@ pub(crate) fn start_session(index: Arc>) -> Result o, + Err(e) => { + unsafe { drop(Box::from_raw(tx_ptr)) }; + stop_session(handle); + let _ = consumer_thread.join(); + return Err(e); + } + }; + + let pump_thread = match std::thread::Builder::new() .name("etw-ocsf-pump".into()) - .spawn(move || process_trace_loop(send_ptr)) - .map_err(|e| { + .spawn(move || run_trace(opened)) + { + Ok(t) => t, + Err(e) => { + // The trace is open but we couldn't spawn the pump. Stop the session, + // reclaim the boxed Sender so the consumer disconnects, and join it. + unsafe { drop(Box::from_raw(tx_ptr)) }; stop_session(handle); - format!("failed to spawn ETW pump thread: {e}") - })?; + let _ = consumer_thread.join(); + return Err(format!("failed to spawn ETW pump thread: {e}")); + } + }; tracing::info!( session = SESSION_NAME, @@ -473,9 +508,13 @@ fn cleanup_stale_session() { // ProcessTrace loop (dedicated blocking thread) // --------------------------------------------------------------------------- +/// Open the real-time consumer with `OpenTraceW` on the **caller** thread so the +/// result is synchronous (review #4). `tx_ptr` is the boxed event `Sender`; on +/// failure the caller reclaims it (we do not drop it here). On success the boxed +/// `Sender` and the `LoggerName` buffer are handed to the returned [`OpenedTrace`] +/// so they outlive the subsequent blocking `ProcessTrace`. #[allow(clippy::field_reassign_with_default)] -fn process_trace_loop(send_ptr: SendPtr) { - let tx_ptr = send_ptr.0; +fn open_trace(tx_ptr: *mut mpsc::Sender) -> Result { let mut name = session_name_wide(); let mut logfile = EVENT_TRACE_LOGFILEW::default(); @@ -485,20 +524,39 @@ fn process_trace_loop(send_ptr: SendPtr) { logfile.Anonymous2.EventRecordCallback = Some(event_record_callback); logfile.Context = tx_ptr.cast::(); - let trace_handle = unsafe { OpenTraceW(&mut logfile) }; - if trace_handle.Value == u64::MAX { - tracing::error!(err = %std::io::Error::last_os_error(), "ETW OpenTraceW failed"); - // Reclaim the boxed Sender so the consumer thread's channel closes. - unsafe { drop(Box::from_raw(tx_ptr)) }; - return; + let handle = unsafe { OpenTraceW(&mut logfile) }; + if handle.Value == u64::MAX { + return Err(format!( + "ETW OpenTraceW failed: {}", + std::io::Error::last_os_error() + )); } - let _ = unsafe { ProcessTrace(&[trace_handle], None, None) }; + Ok(OpenedTrace { + handle, + name, + tx_ptr, + }) +} + +/// Run the blocking `ProcessTrace` pump for an already-opened trace, then clean +/// up. Owns [`OpenedTrace`] for its whole lifetime so the `LoggerName` buffer and +/// boxed `Sender` stay valid until `ProcessTrace` returns. +fn run_trace(opened: OpenedTrace) { + let OpenedTrace { + handle, + name, + tx_ptr, + } = opened; + + let _ = unsafe { ProcessTrace(&[handle], None, None) }; unsafe { - let _ = CloseTrace(trace_handle); + let _ = CloseTrace(handle); drop(Box::from_raw(tx_ptr)); } + // Keep the LoggerName buffer alive until ProcessTrace has fully returned. + drop(name); } unsafe extern "system" fn event_record_callback(event_record: *mut EVENT_RECORD) { From b28465c953e8d84cbdd60aa95d6bb8cbcc4fd489 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 14:51:41 -0600 Subject: [PATCH 07/10] fix(mxc-etw): guard pending-event replay against PID recycling CodeRabbit flagged that drain_resolved() re-resolved buffered events against the live by_pid map, so if Windows recycled a wxc-exec PID within PENDING_TTL a stale event from the dead sandbox could be emitted under the new owner. Stamp each by_pid registration with its Instant and add resolve_replay(), used only on the buffered/replay path. It (a) never falls back to the recycle-/ambiguity-prone by_cmd or last_pid_sid keys, and (b) trusts a PID match only when the registration is not newer than the buffered event by more than REPLAY_PID_GRACE (2s) - a recycled PID's registration lands well outside that window, so the stale event ages out instead of misattributing. The legitimate #2 seed race (registration lands ~immediately) still replays. Adds unit tests for the recycle-refusal, in-grace acceptance, and weak-fallback exclusion. Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 165 ++++++++++++++++-- 1 file changed, 152 insertions(+), 13 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index da707851df..06a8467e5e 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -904,9 +904,27 @@ const PENDING_MAX: usize = 4096; /// unattributable (e.g. an unrelated Sandboxing-provider consumer on the box). const PENDING_TTL: Duration = Duration::from_secs(5); +/// Grace window for trusting a PID match when *replaying* a buffered event. +/// The driver seeds `by_pid` within milliseconds of spawning `wxc-exec`, so a +/// legitimate seed event's registration lands at (or just after) the moment the +/// event was buffered. A recycled PID, by contrast, requires the prior +/// `wxc-exec` to exit and a new one to spawn — far longer than this window — so +/// a registration that is newer than the buffered event by more than this grace +/// is treated as a *different* (recycled) owner and the PID match is refused. +const REPLAY_PID_GRACE: Duration = Duration::from_secs(2); + +/// A `wxc-exec` PID registration: which sandbox owns the PID and *when* it was +/// registered. The timestamp lets the replay path (see [`AttributionIndex:: +/// resolve_replay`]) reject a PID that was recycled to a different sandbox after +/// a still-buffered event was captured. +struct PidReg { + sid: String, + at: Instant, +} + #[derive(Default)] pub(crate) struct AttributionIndex { - by_pid: HashMap, + by_pid: HashMap, by_identity: HashMap, by_activity: HashMap, by_cv: HashMap, @@ -952,17 +970,23 @@ impl AttributionIndex { // and Windows has recycled the number. Rebind to the new owner and drop // the stale per-PID "last resolved" hint so it can't misroute. if let Some(prev) = self.by_pid.get(&wxc_pid) { - if prev != sandbox_id { + if prev.sid != sandbox_id { tracing::warn!( target: "mxc_etw", pid = wxc_pid, - prev = %prev, + prev = %prev.sid, new = %sandbox_id, "wxc-exec PID reused before prior sandbox was forgotten; rebinding attribution" ); } } - self.by_pid.insert(wxc_pid, sandbox_id.to_string()); + self.by_pid.insert( + wxc_pid, + PidReg { + sid: sandbox_id.to_string(), + at: Instant::now(), + }, + ); self.last_pid_sid.remove(&wxc_pid); // Command line is only trustworthy while unique. Promote to `by_cmd` on @@ -987,7 +1011,7 @@ impl AttributionIndex { /// Drop all keys for a finished sandbox to bound memory. pub fn forget(&mut self, sandbox_id: &str) { - self.by_pid.retain(|_, v| v != sandbox_id); + self.by_pid.retain(|_, r| r.sid != sandbox_id); self.by_identity.retain(|_, v| v != sandbox_id); self.by_activity.retain(|_, v| v != sandbox_id); self.by_cv.retain(|_, v| v != sandbox_id); @@ -1023,7 +1047,7 @@ impl AttributionIndex { let sid = self .by_pid .get(&ev.process_id) - .cloned() + .map(|registration| registration.sid.clone()) .or_else(|| { identity .as_ref() @@ -1038,18 +1062,78 @@ impl AttributionIndex { .or_else(|| cmd.as_ref().and_then(|c| self.by_cmd.get(c).cloned())) .or_else(|| self.last_pid_sid.get(&ev.process_id).cloned())?; + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Resolve a *buffered* (replayed) event. Unlike [`Self::resolve`], this is + /// hardened against PID recycling and command-line ambiguity that can occur + /// during the buffer window ([`PENDING_TTL`]): + /// + /// - It **never** falls back to `by_cmd` or `last_pid_sid` — both are + /// recycle-/ambiguity-prone and a stale entry could bind a buffered event + /// to the wrong sandbox. + /// - A `by_pid` match is only trusted if the PID's registration is not newer + /// than the buffered event by more than [`REPLAY_PID_GRACE`]. If the PID + /// was recycled to a *different* sandbox after this event was captured, the + /// registration timestamp will be well beyond the grace window and the PID + /// match is refused (the event stays buffered and ages out rather than + /// being misattributed to the new owner). + /// + /// Strong, per-sandbox-unique correlators (`identity`, `activity`, CV) are + /// always trusted — they are cross-linked from the driver-owned PID anchor + /// and are not reused across sandboxes. + fn resolve_replay(&mut self, ev: &DecodedEtwEvent, buffered_at: Instant) -> Option { + let identity = ev.identity(); + let cv = ev.cv_base(); + let activity = guid_key(&ev.activity_id); + + let sid = identity + .as_ref() + .and_then(|i| self.by_identity.get(i).cloned()) + .or_else(|| { + activity + .as_ref() + .and_then(|a| self.by_activity.get(a).cloned()) + }) + .or_else(|| cv.as_ref().and_then(|c| self.by_cv.get(c).cloned())) + .or_else(|| { + self.by_pid.get(&ev.process_id).and_then(|r| { + // Refuse a PID that was (re)registered well after this event + // was buffered — that registration belongs to a recycled PID + // owned by a different sandbox, not this event's emitter. + if r.at <= buffered_at + REPLAY_PID_GRACE { + Some(r.sid.clone()) + } else { + None + } + }) + })?; + + self.cross_link(&sid, identity, cv, activity, ev.process_id); + Some(sid) + } + + /// Cross-link the strong keys an event carries to its resolved `sandbox_id` + /// so later keyless events for the same sandbox attribute correctly. + fn cross_link( + &mut self, + sid: &str, + identity: Option, + cv: Option, + activity: Option, + pid: u32, + ) { if let Some(i) = identity { - self.by_identity.entry(i).or_insert_with(|| sid.clone()); + self.by_identity.entry(i).or_insert_with(|| sid.to_string()); } if let Some(c) = cv { - self.by_cv.entry(c).or_insert_with(|| sid.clone()); + self.by_cv.entry(c).or_insert_with(|| sid.to_string()); } if let Some(a) = activity { - self.by_activity.entry(a).or_insert_with(|| sid.clone()); + self.by_activity.entry(a).or_insert_with(|| sid.to_string()); } - self.last_pid_sid.insert(ev.process_id, sid.clone()); - - Some(sid) + self.last_pid_sid.insert(pid, sid.to_string()); } /// Hold an event that didn't resolve yet, evicting expired and (if needed) @@ -1091,7 +1175,7 @@ impl AttributionIndex { tracing::debug!(target: "mxc_etw", pid = p.ev.process_id, "dropping unattributed (aged out) {}", p.ev.summary()); continue; } - match self.resolve(&p.ev) { + match self.resolve_replay(&p.ev, p.at) { Some(sid) => { let name = self.name_of(&sid); ready.push((sid, name, p.ev)); @@ -1651,4 +1735,59 @@ mod tests { .push(("commandLine".into(), "\"agent --unique\"".into())); assert_eq!(idx.resolve(&uniq).as_deref(), Some("sbx-3")); } + + // CodeRabbit (replay PID recycle): a buffered event whose only key is a PID + // must NOT be replayed onto a sandbox that registered that PID *after* the + // event was captured — that registration is a recycled PID owned by someone + // else. Refusing (event ages out) beats misattributing to the new owner. + #[test] + fn replayed_pid_match_refused_after_recycle() { + let mut idx = AttributionIndex::new(); + // Stale event for a now-dead sandbox, buffered a while ago. + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now() - Duration::from_secs(3); + + // PID 1000 is recycled and registered to a brand-new sandbox *now*. + idx.register_launch("sbx-new", "new", 1000, "agent"); + + assert!( + idx.resolve_replay(&ev, buffered_at).is_none(), + "stale PID-only event must not bind to the recycled PID's new owner" + ); + } + + // The legitimate #2 seed race is preserved: an event buffered essentially + // when the driver seeds attribution still replays via its PID. + #[test] + fn replayed_pid_match_accepted_within_grace() { + let mut idx = AttributionIndex::new(); + let ev = mk_event(1000, "CreateProcessInSandbox"); + let buffered_at = Instant::now(); + idx.register_launch("sbx-1", "s1", 1000, "agent"); + assert_eq!( + idx.resolve_replay(&ev, buffered_at).as_deref(), + Some("sbx-1"), + "a seed event buffered at registration time must still replay" + ); + } + + // Replay must not lean on the weak fallbacks (`by_cmd` / `last_pid_sid`): + // a buffered event whose only match is a command line is refused on replay + // (it would be resolved on the live path, but is too weak to trust after a + // buffering delay). + #[test] + fn replay_ignores_weak_fallbacks() { + let mut idx = AttributionIndex::new(); + idx.register_launch("sbx-1", "s1", 11, "agent --unique"); + + let mut only_cmd = mk_event(999, "SandboxConfig"); + only_cmd + .props + .push(("commandLine".into(), "\"agent --unique\"".into())); + + // Live path would resolve it via by_cmd... + // (not asserted here to avoid mutating cross-links) + // ...but the replay path refuses the weak command-line key. + assert!(idx.resolve_replay(&only_cmd, Instant::now()).is_none()); + } } From 661270a9202691b1d69b25e028c7abd59d89c5f2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 15:12:40 -0600 Subject: [PATCH 08/10] fix(mxc-etw): surface unexpected ProcessTrace termination (review #4) start_session already returns Err on OpenTraceW failure (runs on the caller thread since e41a7701), closing the first half of Shailendra's #4. This closes the second half: ProcessTrace's result was discarded, so if capture died mid-run the backend had no way to know. Add a shared CaptureHealth (stopped/stopping/exit_code) between the pump thread and EtwSession. run_trace now records ProcessTrace's WIN32_ERROR and, when the pump returns without a deliberate stop, logs at ERROR that MXC OCSF capture is no longer running. EtwSession::stop() sets `stopping` before teardown so a normal shutdown isn't misreported, and EtwSession::is_capture_alive() exposes the state for status/diagnostics. Box-verified on 7F203-MXC-001: 5 sandboxes, 50 attributed OCSF rows, JSONL parity 50/50, BuffersLost=0, clean start/stop (no false failure). Signed-off-by: Akber Raza --- .../openshell-driver-mxc/src/etw_consumer.rs | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 06a8467e5e..8303ba8ea9 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -52,6 +52,7 @@ use std::collections::{HashMap, VecDeque}; use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -215,17 +216,39 @@ impl DecodedEtwEvent { // Session handle (RAII) // --------------------------------------------------------------------------- +/// Health of the blocking `ProcessTrace` pump, shared between the pump thread and +/// the owning [`EtwSession`] (review #4). Previously `ProcessTrace`'s result was +/// discarded, so if capture died mid-run (e.g. the session was stopped out from +/// under us) the backend had no way to know. The pump records its outcome here so +/// an *unexpected* termination is logged at ERROR and can be queried via +/// [`EtwSession::is_capture_alive`]. +#[derive(Default)] +struct CaptureHealth { + /// Set once the pump's `ProcessTrace` has returned (capture is no longer running). + stopped: AtomicBool, + /// Set by [`EtwSession::stop`] *before* stopping the session, so a deliberate + /// shutdown isn't misreported as a capture failure. + stopping: AtomicBool, + /// The `WIN32_ERROR` code `ProcessTrace` returned (0 == `ERROR_SUCCESS`). + /// Only meaningful once `stopped` is set. + exit_code: AtomicU32, +} + /// A running real-time ETW session plus its worker threads. Dropping (or calling /// [`EtwSession::stop`]) stops the session and joins the threads. pub(crate) struct EtwSession { handle: u64, pump_thread: Option>, consumer_thread: Option>, + health: Arc, } impl EtwSession { /// Stop the session and join worker threads. Idempotent. pub fn stop(&mut self) { + // Mark the stop as expected *before* triggering it so the pump thread's + // `ProcessTrace` return isn't logged as an unexpected capture death. + self.health.stopping.store(true, Ordering::SeqCst); if self.handle != 0 { stop_session(self.handle); self.handle = 0; @@ -240,6 +263,14 @@ impl EtwSession { let _ = t.join(); } } + + /// Whether the `ProcessTrace` pump is still running. Returns `false` once the + /// pump has returned — whether from a deliberate [`stop`](Self::stop) or an + /// unexpected termination. Exposed so the backend can surface capture health + /// in status/diagnostics (review #4). + pub fn is_capture_alive(&self) -> bool { + !self.health.stopped.load(Ordering::SeqCst) + } } impl Drop for EtwSession { @@ -336,9 +367,11 @@ pub(crate) fn start_session(index: Arc>) -> Result t, Err(e) => { @@ -360,6 +393,7 @@ pub(crate) fn start_session(index: Arc>) -> Result) -> Result) { let OpenedTrace { handle, name, tx_ptr, } = opened; - let _ = unsafe { ProcessTrace(&[handle], None, None) }; + let status = unsafe { ProcessTrace(&[handle], None, None) }; + + // Record the outcome before any cleanup so a health query never races a + // still-"alive" state after the pump has actually returned. + health.exit_code.store(status.0, Ordering::SeqCst); + health.stopped.store(true, Ordering::SeqCst); + + let expected = health.stopping.load(Ordering::SeqCst); + if !expected { + // The session went away without anyone asking it to (e.g. an external + // `logman stop`, a provider error, or a dropped trace). Surface it — the + // OCSF audit trail is now blind until the driver is restarted. + tracing::error!( + target: "mxc_etw", + code = status.0, + "ETW ProcessTrace terminated unexpectedly; MXC OCSF capture is no longer running" + ); + } else { + tracing::debug!(target: "mxc_etw", code = status.0, "ETW ProcessTrace returned after stop"); + } unsafe { let _ = CloseTrace(handle); From 26fd1132cb7a4b7f74ceedbf947c0b5fffdbc29b Mon Sep 17 00:00:00 2001 From: Jamie King Date: Tue, 14 Jul 2026 22:26:38 -0600 Subject: [PATCH 09/10] =?UTF-8?q?=EF=BB=BFfeat(mxc-ocsf):=20add=20ETW->OCS?= =?UTF-8?q?F=20audit-trail=20example=20kit;=20fix=20proxy-configured=20mes?= =?UTF-8?q?sage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a runnable OCSF audit-trail example under examples/ (run-ocsf-audit.ps1, mxc-ocsf-audit.toml, ocsf-audit.yaml, README) that spins up sandboxes with the in-process ETW consumer and egress proxy on, emitting a full OCSF JSONL audit trail across all four classes (6002/5019/1007/2004). Fix SandboxProxyConfigured mapping to log "MXC sandbox proxy configured" instead of a misleading "(no proxy)" when the provider reports proxyPort=0; the event's presence already indicates proxy configuration. Verified on-box: 26 events, all mapped ETW event types present. Signed-off-by: Akber Raza --- .../examples/README-ocsf-audit.txt | 75 ++++ .../examples/mxc-ocsf-audit.toml | 54 +++ .../examples/ocsf-audit.yaml | 19 + .../examples/run-ocsf-audit.ps1 | 351 ++++++++++++++++++ .../openshell-driver-mxc/src/etw_consumer.rs | 21 +- 5 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 crates/openshell-driver-mxc/examples/README-ocsf-audit.txt create mode 100644 crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml create mode 100644 crates/openshell-driver-mxc/examples/ocsf-audit.yaml create mode 100644 crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt new file mode 100644 index 0000000000..26d4af2ef3 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -0,0 +1,75 @@ +OpenShell MXC - ETW -> OCSF audit-trail example +=============================================== + +WHAT THIS PROVES / PRODUCES + The full Windows OCSF audit path on this box: + gateway -> MXC driver -> process_container sandbox + -> the OS "Sandboxing" ETW provider fires as the sandbox is created + -> the gateway's in-process consumer decodes each event, attributes it to + an OpenShell sandbox_id, and maps it to OCSF + -> events are written to a durable JSONL audit log AND printed as + human-readable shorthand. + + The deliverable is the OCSF log: openshell-ocsf..log, one OCSF event + object per line - the same schema and medium the Linux OpenShell pipeline + produces (Windows is at functional parity). + + OCSF classes you will see: + [6002] Application Lifecycle - sandbox created + [5019] Device Config State Change - OS policy / hardening / proxy / console + [1007] Process Activity - in-sandbox process launch (+ cmd line) + [2004] Detection Finding - MXC setup activity errors (informational) + +PREREQUISITES (on this test box) + - wxc-exec.exe present (default expected: C:\mxc-kit\bin\wxc-exec.exe) + - process_container backend live (it was for our earlier runs) + - Run ELEVATED (Run as administrator) OR from an account in the + 'Performance Log Users' group. Opening the real-time ETW session needs this; + without it the run fails fast with a clear message. + +HOW TO RUN + 1. Open an ELEVATED PowerShell in THIS folder. + 2. Run: + powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 + If wxc-exec is somewhere else: + ... -File .\run-ocsf-audit.ps1 -WxcExecPath "D:\path\to\wxc-exec.exe" + +WHAT YOU GET BACK + The script prints PASS/FAIL + a class/event breakdown and creates: + results-.zip + Hand that zip back. It contains the OCSF audit log (openshell-ocsf..log), + the full transcript, the gateway logs (with the human-readable OCSF shorthand), + a summary, and the exact config + policy used. The bundle is also auto-copied + to the shared drive for pickup (pass -ShareOut "" to disable that). + +FILES IN THIS PACKAGE + openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) + openshell.exe the CLI + mxc-ocsf-audit.toml gateway/driver config (process_container, etw_audit=true, egress proxy) + ocsf-audit.yaml sandbox policy (read-write grant to the share dir) + run-ocsf-audit.ps1 the orchestrator you run + README-ocsf-audit.txt this file + (wxc-exec.exe is used IN PLACE on the box; not shipped) + +USEFUL OPTIONS + -SandboxCount Create n sandboxes (default 2). More sandboxes = more events. + -NoProxy Skip the per-sandbox egress proxy. This omits ONLY the + SandboxProxyConfigured config event; everything else is + still produced. (Default is proxy ON for the full set.) + -WxcExecPath Path to wxc-exec.exe on this box. + -ShareOut "" Disable the auto-copy of the results bundle to the share. + -KeepRunning Leave the gateway running afterward for inspection. + +NOTES + - The control plane between CLI and gateway runs with --disable-tls on loopback; + that is unrelated to the OCSF audit path this example exercises. + - A "supervisor session not connected" / ssh 255 message during sandbox create + is EXPECTED on MXC and harmless - the agent already ran in-driver. + - The proxy path requires the host-side CONNECT proxy and an absolute agent + binary (the packaged config uses C:\Windows\System32\cmd.exe); the run script + handles this for you. + - The Sandboxing provider reports the sandbox entry-point process, not the full + in-sandbox process tree. Deep process-tree auditing would need a second ETW + source (Microsoft-Windows-Kernel-Process) and is out of scope for this trail. + - cmd_line is captured verbatim into OCSF process.cmd_line with no redaction on + this path; treat the audit log as sensitive at rest and in transit. diff --git a/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml new file mode 100644 index 0000000000..d41e08efe3 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/mxc-ocsf-audit.toml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# MXC gateway config for the ETW -> OCSF audit-trail example. +# +# Goal: exercise the in-process ETW consumer (Plane A) end-to-end so that +# creating a sandbox produces a full OCSF audit trail — Application Lifecycle +# [6002], Device Config State Change [5019], Process Activity [1007] and +# Detection Finding [2004] — written to a durable JSONL log, just like the Linux +# OCSF pipeline. +# +# run-ocsf-audit.ps1 patches wxc_exec_path, backend, etw_audit, the egress-proxy +# switch and agent_command into a disposable copy of this file, so the values +# here are sane defaults; edit them if you run the gateway directly. + +[openshell.drivers.mxc] +# Path to wxc-exec.exe on the box (patched by the run script; default is the +# location observed on the MXC test boxes). +wxc_exec_path = "C:\\mxc-kit\\bin\\wxc-exec.exe" + +# One-shot AppContainer. This is the backend whose Sandboxing ETW the consumer +# captures. (isolation_session is "dark" — it emits no provider events.) +backend = "process_container" + +default_configuration_id = "composable" + +# Host folder mapped read-write into the sandbox. +share_dir = "C:/work/openshell-mxc-demo" +agent_cwd = "C:/work/openshell-mxc-demo" + +# A simple in-policy write — enough to make wxc-exec provision an AppContainer and +# drive the Sandboxing provider. Absolute cmd.exe path is REQUIRED when the egress +# proxy is on (the host proxy hashes agent_command[0] as its static identity +# binary, so it must be an absolute, existing exe). +agent_command = [ + "C:\\Windows\\System32\\cmd.exe", + "/c", + "echo hello from openshell ocsf audit 1>C:\\work\\openshell-mxc-demo\\hello.txt", +] + +debug = false + +# Turn ON the Plane-A ETW -> OCSF audit consumer. This is the core of the example. +etw_audit = true + +# Per-sandbox governed egress. Enabling this makes the driver start a host CONNECT +# proxy and hand MXC a `network.proxy` redirect, which is what makes MXC emit the +# SandboxProxyConfigured event — the config event mapped to OCSF CONFIG [5019] +# that completes full event coverage. Requires backend = process_container and a +# loopback (127.0.0.1) seed address; the driver allocates a unique ephemeral port +# per sandbox from this seed. Run-ocsf-audit.ps1 disables this when passed +# -NoProxy. +egress_proxy = true +egress_proxy_addr = "127.0.0.1:18080" diff --git a/crates/openshell-driver-mxc/examples/ocsf-audit.yaml b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml new file mode 100644 index 0000000000..ade2f69eec --- /dev/null +++ b/crates/openshell-driver-mxc/examples/ocsf-audit.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# ocsf-audit.yaml — sandbox policy for the MXC ETW -> OCSF audit-trail example. +# +# Minimal filesystem policy granting the shared host folder read-write; everything +# else is default-deny. The granted path MUST match `share_dir` / +# OPENSHELL_MXC_SHARE_DIR in mxc-ocsf-audit.toml. +# +# No network_policies block is needed here: the per-sandbox egress proxy is driven +# by `egress_proxy = true` in mxc-ocsf-audit.toml (that is what makes MXC emit the +# SandboxProxyConfigured event we map to OCSF), not by a policy rule. +version: 1 + +filesystem_policy: + include_workdir: false + read_only: [] + read_write: + - "C:/work/openshell-mxc-demo" diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 new file mode 100644 index 0000000000..8c723a84e6 --- /dev/null +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -0,0 +1,351 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# run-ocsf-audit.ps1 - gateway-driven ETW -> OCSF audit-trail example for OpenShell/MXC. +# +# Proves the FULL product path on the test box AND produces a durable OCSF log: +# start gateway (etw_audit on, OCSF JSONL on) -> register CLI -> +# create N sandboxes (each drives the OS "Sandboxing" ETW provider) -> +# the in-process consumer decodes, attributes, and maps every event to OCSF -> +# tear the sandboxes + gateway down -> collect the OCSF log + every artifact +# into a results\ folder -> zip it. +# +# The deliverable is the OCSF audit log itself: openshell-ocsf..log, a +# durable JSONL file with one OCSF event object per line - the same schema and +# medium the Linux OpenShell pipeline produces. +# +# MUST RUN ELEVATED. Opening the real-time ETW session requires an elevated shell +# (Run as administrator) or an account in the 'Performance Log Users' group. +# +# Run from inside the package folder (gateway + cli + mxc-ocsf-audit.toml + +# ocsf-audit.yaml + this script all sit together): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File .\run-ocsf-audit.ps1 ` +# -WxcExecPath C:\mxc-kit\bin\wxc-exec.exe +# +# By default the per-sandbox egress proxy is ON so the full event set (including +# SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. +# +# Hand the produced results-*.zip back for evaluation. + +[CmdletBinding()] +param( + # Real wxc-exec on the test box. + [string] $WxcExecPath = "C:\mxc-kit\bin\wxc-exec.exe", + # Host folder mapped read-write into the sandbox (must match ocsf-audit.yaml). + [string] $ShareDir = "C:\work\openshell-mxc-demo", + # How many sandboxes to create (each drives a full event burst). + [int] $SandboxCount = 2, + # Disable the per-sandbox egress proxy (omits the SandboxProxyConfigured event). + [switch] $NoProxy, + # Gateway bind port (matches the gateway default) + CLI registration name. + [int] $Port = 17670, + [string] $GatewayName = "openshell-mxc-ocsf", + # Internal driver ETW session name (used to clean up a leaked session). + [string] $SessionName = "OpenShell-MXC-ETW", + # Shared drive the results bundle is auto-copied to for pickup/analysis. + # Set to "" to disable the push. + [string] $ShareOut = "\\nvsw-dump\users\jamiek\prashant", + # Leave the gateway running afterward (for inspection). + [switch] $KeepRunning +) + +$ErrorActionPreference = "Stop" +# Don't let expected non-zero CLI exits (e.g. the post-create attach) throw on PS 7.4+. +$PSNativeCommandUseErrorActionPreference = $false +try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$here = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } + +# Results bundle (everything we hand back) ------------------------------------ +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultDir = Join-Path $here "results-$stamp" +New-Item -ItemType Directory -Force $resultDir | Out-Null +Start-Transcript -Path (Join-Path $resultDir "transcript.txt") -Force | Out-Null + +function Step([string]$m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } +function Info([string]$m) { Write-Host " $m" } +function Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Bad([string]$m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +$gateway = Join-Path $here "openshell-gateway.exe" +$cli = Join-Path $here "openshell.exe" +$policy = Join-Path $here "ocsf-audit.yaml" +$tomlSrc = Join-Path $here "mxc-ocsf-audit.toml" +$toml = Join-Path $resultDir "mxc-ocsf-audit.used.toml" # disposable patched copy (bundled) + +$gw = $null +$passed = $true +$proxyOn = -not $NoProxy + +try { + # 1. Validate artifacts + privilege. + Step "Validate package artifacts" + foreach ($f in @($gateway, $cli, $policy, $tomlSrc)) { + if (-not (Test-Path $f)) { throw "missing artifact: $f (run this script from inside the package folder)" } + Info "found $(Split-Path $f -Leaf)" + } + Info "machine : $env:COMPUTERNAME user: $env:USERNAME PS: $($PSVersionTable.PSVersion)" + + # Opening the real-time ETW session requires elevation or 'Performance Log Users'. + $wid = [Security.Principal.WindowsIdentity]::GetCurrent() + $wp = New-Object Security.Principal.WindowsPrincipal($wid) + $admin = $wp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + $plu = $wp.IsInRole((New-Object Security.Principal.SecurityIdentifier("S-1-5-32-559"))) + Info "elevated=$admin perfLogUsers=$plu" + if (-not $admin -and -not $plu) { + throw "This run must open a real-time ETW session, which needs elevation. Re-run from an elevated shell (Run as administrator) or add this account to 'Performance Log Users'." + } + + if (-not (Test-Path $WxcExecPath)) { + throw "wxc-exec not found at '$WxcExecPath'. Pass -WxcExecPath pointing at the real binary." + } + Info "wxc-exec: $WxcExecPath" + + # 2. Patch the disposable TOML copy: wxc path + backend + etw_audit + egress. + Step "Patch gateway config (disposable copy)" + $tomlText = Get-Content $tomlSrc -Raw + $escaped = $WxcExecPath.Replace('\', '\\') + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*wxc_exec_path\s*=.*$', "wxc_exec_path = `"$escaped`"") + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*backend\s*=.*$', 'backend = "process_container"') + if ($tomlText -match '(?m)^\s*#?\s*etw_audit\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*etw_audit\s*=.*$', 'etw_audit = true') + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`netw_audit = true") + } + $proxyVal = if ($proxyOn) { 'true' } else { 'false' } + if ($tomlText -match '(?m)^\s*#?\s*egress_proxy\s*=') { + $tomlText = [regex]::Replace($tomlText, '(?m)^\s*#?\s*egress_proxy\s*=.*$', "egress_proxy = $proxyVal") + } else { + $tomlText = [regex]::Replace($tomlText, '(?m)^\[openshell\.drivers\.mxc\]\s*$', "[openshell.drivers.mxc]`r`negress_proxy = $proxyVal") + } + Set-Content $toml -Value $tomlText -Encoding UTF8 + Copy-Item $policy (Join-Path $resultDir "ocsf-audit.used.yaml") -Force + Info "backend=process_container etw_audit=true egress_proxy=$proxyVal" + + # 3. Port must be free. Auto-clear a stale OUR-gateway; refuse anything else. + Step "Check gateway port $Port is free" + $busy = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue + if ($busy) { + $owner = Get-Process -Id $busy.OwningProcess -ErrorAction SilentlyContinue + if ($owner -and $owner.Name -eq "openshell-gateway") { + Info "stale gateway on port $Port (pid $($owner.Id)) - stopping it" + Stop-Process -Id $owner.Id -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } else { + throw "port $Port in use by '$($owner.Name)' (pid $($busy.OwningProcess)) - not our gateway; stop it and retry." + } + } + Ok "port $Port free" + + # 4. ETW session pre-flight. A force-killed gateway never runs Drop, so its + # real-time ETW session LEAKS and can starve the next run's capture. Stop + # any leftover before we start. + Step "ETW session pre-flight" + $leaked = @(logman query -ets 2>$null | Select-String -SimpleMatch $SessionName) + Info "leaked '$SessionName' sessions before run: $($leaked.Count)" + if ($leaked.Count -gt 0) { logman stop $SessionName -ets 2>&1 | Out-Null; Info "stopped leaked session(s)" } + + # 5. Prepare share folder. + New-Item -ItemType Directory -Force $ShareDir | Out-Null + Remove-Item (Join-Path $ShareDir "hello.txt") -Force -ErrorAction SilentlyContinue + + # 6. Gateway environment. Enable the durable OCSF JSONL audit sink and point it + # at THIS run's dir so the log lands directly in the bundle. + $env:OPENSHELL_DRIVERS = "mxc" + $env:OPENSHELL_MXC_SHARE_DIR = $ShareDir + $env:OPENSHELL_WXC_EXEC_PATH = $WxcExecPath + $env:OPENSHELL_OCSF_JSON = "1" + $env:OPENSHELL_OCSF_LOG_DIR = $resultDir + Remove-Item Env:OPENSHELL_MXC_MOCK_WXC -ErrorAction SilentlyContinue + + # 7. Start the gateway (background, TLS disabled on the loopback control plane). + Step "Start gateway (OCSF audit on)" + $gwLog = Join-Path $resultDir "gateway.log" + $gwErrLog = Join-Path $resultDir "gateway.err.log" + $gw = Start-Process -FilePath $gateway ` + -ArgumentList @("--disable-tls", "--config", $toml, "--log-level", "info") ` + -WorkingDirectory $here -PassThru -NoNewWindow ` + -RedirectStandardOutput $gwLog -RedirectStandardError $gwErrLog + Info "gateway pid $($gw.Id); logs -> $(Split-Path $gwLog -Leaf) (+ .err)" + + # 8. Wait until the gateway is listening. + $deadline = (Get-Date).AddSeconds(30); $ready = $false + while ((Get-Date) -lt $deadline) { + if ($gw.HasExited) { + Get-Content $gwLog, $gwErrLog -ErrorAction SilentlyContinue | ForEach-Object { Info $_ } + throw "gateway exited early (code $($gw.ExitCode)). See logs above." + } + if (Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) { $ready = $true; break } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "gateway did not start listening on $Port within 30s." } + Ok "gateway listening on 127.0.0.1:$Port" + + # 9. Register CLI -> gateway. + Step "Register CLI -> gateway" + $env:OPENSHELL_GATEWAY = "" + try { & $cli gateway add "http://127.0.0.1:$Port" --local --name $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway add: $($_.Exception.Message) (continuing - likely already registered)" } + try { & $cli gateway select $GatewayName 2>&1 | ForEach-Object { Info $_ } } + catch { Info "gateway select: $($_.Exception.Message) (continuing)" } + Ok "selected gateway '$GatewayName'" + + # 10. Create N sandboxes. Each drives the Sandboxing provider -> a full OCSF + # event burst. The post-create interactive attach failure is EXPECTED on + # MXC (no in-sandbox supervisor) and harmless - the agent already ran. + Step "Create $SandboxCount sandbox(es) (drives the Sandboxing provider)" + for ($i = 1; $i -le $SandboxCount; $i++) { + $name = "ocsf$i" + Info "-- creating $name --" + try { & $cli sandbox create --name $name --policy $policy --no-tty -- exit 2>&1 | ForEach-Object { Info $_ } } + catch { Info "sandbox create attach: $($_.Exception.Message) (expected on MXC - agent ran in-driver; continuing)" } + Start-Sleep -Seconds 3 + try { & $cli sandbox delete $name 2>&1 | Out-Null } catch {} + } +} +catch { + Bad $_.Exception.Message + $passed = $false +} +finally { + # Stop the gateway FIRST so it releases its log + JSONL file handles. + if ($KeepRunning -and $gw -and -not $gw.HasExited) { + Info "leaving gateway pid $($gw.Id) running (-KeepRunning); stop it with: Stop-Process -Id $($gw.Id) -Force" + } elseif ($gw -and -not $gw.HasExited) { + Step "Cleanup" + Stop-Process -Id $gw.Id -Force -ErrorAction SilentlyContinue + try { $gw.WaitForExit(5000) | Out-Null } catch {} + Info "stopped gateway pid $($gw.Id)" + } + # Belt-and-suspenders: force-kill skips Drop, so stop the leaked session here. + if (-not $KeepRunning) { logman stop $SessionName -ets 2>&1 | Out-Null } + + # ---- summarise the OCSF audit trail -------------------------------------- + $logText = @() + if (Test-Path (Join-Path $resultDir "gateway.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.log") } + if (Test-Path (Join-Path $resultDir "gateway.err.log")) { $logText += Get-Content (Join-Path $resultDir "gateway.err.log") } + # The gateway writes ANSI colour codes even when redirected; strip them so + # matches are reliable. + $esc = [char]27 + $logText = $logText | ForEach-Object { $_ -replace "$esc\[[0-9;]*m", "" } + + $consumerStarted = [bool]($logText | Select-String -SimpleMatch "consumer started" -Quiet) + $consumerFailed = [bool]($logText | Select-String -SimpleMatch "ETW audit consumer failed to start" -Quiet) + + # Locate the durable OCSF JSONL audit log and tally by OCSF class. + $jsonlFiles = @(Get-ChildItem -Path $resultDir -Filter "openshell-ocsf*.log" -ErrorAction SilentlyContinue) + $jsonlPath = if ($jsonlFiles.Count) { $jsonlFiles[0].FullName } else { $null } + $classNames = @{ 6002 = "Application Lifecycle"; 5019 = "Device Config State Change"; 1007 = "Process Activity"; 2004 = "Detection Finding" } + $classCounts = @{ 6002 = 0; 5019 = 0; 1007 = 0; 2004 = 0 } + $jsonlCount = 0; $jsonlBad = 0; $sids = @(); $hosts = @() + if ($jsonlPath) { + $raw = @(Get-Content $jsonlPath -ErrorAction SilentlyContinue | Where-Object { $_.Trim() -ne "" }) + $jsonlCount = $raw.Count + foreach ($line in $raw) { + try { + $o = $line | ConvertFrom-Json + if ($o.class_uid -ne $null -and $classCounts.ContainsKey([int]$o.class_uid)) { $classCounts[[int]$o.class_uid]++ } + if ($o.metadata -and $o.metadata.uid) { $sids += [string]$o.metadata.uid } + if ($o.device -and $o.device.hostname) { $hosts += [string]$o.device.hostname } + } catch { $jsonlBad++ } + } + $sids = @($sids | Select-Object -Unique) + $hosts = @($hosts | Select-Object -Unique) + } + + # Named-event checklist (detected from the human-readable shorthand lines). + function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } + $events = [ordered]@{ + "Sandbox created (lifecycle)" = Seen "(?i)ocsf:.*LIFECYCLE:" + "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" + "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" + "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" + "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" + "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" + "proxy configured" = Seen "(?i)ocsf:.*proxy configured" + "process launch (cmd line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" + "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + } + $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count + if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($classesSeen -ge 3) } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } + $eventLines = foreach ($k in $events.Keys) { " {0} {1}" -f $(if ($events[$k]) { "[x]" } else { "[ ]" }), $k } + + Step "RESULT" + $summary = @" +OpenShell MXC ETW -> OCSF audit trail +===================================== +timestamp : $stamp +machine : $env:COMPUTERNAME +user : $env:USERNAME (admin=$admin perfLogUsers=$plu) +verdict : $verdict +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits SandboxProxyConfigured)' }) +wxc_exec : $WxcExecPath +backend : process_container +gateway_port : $Port +sandboxes : $SandboxCount (distinct sandbox_ids in log: $($sids.Count)) + +OCSF audit log (durable JSONL, one event per line): + file : $(if ($jsonlPath) { Split-Path $jsonlPath -Leaf } else { '(none written)' }) + events : $jsonlCount invalid-json: $jsonlBad host: $($hosts -join ',') + +OCSF classes captured: +$($classLines -join "`r`n") + +Event coverage (from the human-readable shorthand): +$($eventLines -join "`r`n") + +Files in this bundle: + transcript.txt full console transcript + gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) + openshell-ocsf..log THE DELIVERABLE: durable OCSF audit trail (JSONL) + summary.txt this summary + mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) + ocsf-audit.used.yaml the exact sandbox policy used + +What PASS means: the gateway launched sandbox(es), the in-process ETW consumer +started, decoded the Sandboxing provider, attributed each event to a sandbox_id, +mapped them to OCSF, and wrote a durable JSONL audit log spanning $classesSeen event classes - +the full Windows OCSF path end-to-end, at parity with the Linux pipeline. +"@ + Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 + Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) + + try { Stop-Transcript | Out-Null } catch {} + + # Zip the bundle for easy return (defensive; never throw out of finally). + try { + $zip = Join-Path $here "results-$stamp.zip" + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force + Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow + Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } + + # Auto-push the bundle to the shared drive for pickup/analysis (skip if we + # already ran from the share, or if -ShareOut "" disables it). + if (-not [string]::IsNullOrWhiteSpace($ShareOut)) { + try { + $alreadyThere = $false + try { if ((Resolve-Path $here).Path -eq (Resolve-Path $ShareOut -ErrorAction SilentlyContinue).Path) { $alreadyThere = $true } } catch {} + if ($alreadyThere) { + Write-Host "PUSHED: results-$stamp (ran from share; already there)" -ForegroundColor Green + } elseif (Test-Path $ShareOut) { + if ($zip -and (Test-Path $zip)) { Copy-Item $zip (Join-Path $ShareOut "results-$stamp.zip") -Force } + Write-Host "PUSHED: results-$stamp.zip -> $ShareOut" -ForegroundColor Green + } else { + Write-Host "share not reachable: $ShareOut (results local only at $resultDir)" -ForegroundColor Yellow + } + } catch { Write-Host "push failed: $($_.Exception.Message)" -ForegroundColor Yellow } + } + + Write-Host "`nYour OCSF audit log:" -ForegroundColor Cyan + Write-Host " $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' })" -ForegroundColor Green +} + +if ($passed) { exit 0 } else { exit 1 } diff --git a/crates/openshell-driver-mxc/src/etw_consumer.rs b/crates/openshell-driver-mxc/src/etw_consumer.rs index 8303ba8ea9..0503a4216b 100644 --- a/crates/openshell-driver-mxc/src/etw_consumer.rs +++ b/crates/openshell-driver-mxc/src/etw_consumer.rs @@ -1356,6 +1356,10 @@ fn emit_resolved( | "EnforceOsPolicy" | "SandboxProxyConfigured" | "SandboxConsoleReferencePlumbed" => { + // Dump the raw decoded field set for config-family events at debug so we + // can confirm the exact property names MXC emits (e.g. which key carries + // the proxy port on `SandboxProxyConfigured`). Guarded by `debug=true`. + tracing::debug!(target: "mxc_etw", pid = ev.process_id, sandbox_id = %sandbox_id, "{}", ev.summary()); let ctx = etw_ctx(sandbox_id, sandbox_name); emit_ocsf(sandbox_id, map_config_state(&ctx, ev)); } @@ -1412,11 +1416,20 @@ fn map_config_state(ctx: &SandboxContext, ev: &DecodedEtwEvent) -> OcsfEvent { "ApplyUILimits" => "MXC sandbox UI restrictions applied".to_string(), "EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(), "SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(), - // The one network-plane event the provider emits; `proxyPort=0` means no - // proxy was configured. Surface the port so the CONFIG row is self-describing. + // The one network-plane event the provider emits. Empirically the OS + // Sandboxing provider fires this event *only* when an egress proxy is + // configured for the sandbox, but it does **not** surface the port for + // MXC's URL-based proxy — `proxyPort` is always 0 (MXC redirects egress via + // a `network.proxy.localhost` policy URL, not the OS built-in proxy-port + // mechanism this field reflects). The real per-sandbox listening port is + // recorded on the host proxy's own Network Activity [4001] "Listen" event. + // So the presence of this event means a proxy WAS configured; only append a + // port on the off chance a future provider/build populates it. "SandboxProxyConfigured" => match ev.get_unquoted("proxyPort").as_deref() { - Some("0") | None => "MXC sandbox proxy configured (no proxy)".to_string(), - Some(port) => format!("MXC sandbox proxy configured (port {port})"), + Some(port) if port != "0" => { + format!("MXC sandbox proxy configured (port {port})") + } + _ => "MXC sandbox proxy configured".to_string(), }, _ => "MXC sandbox OS policy configured".to_string(), }; From 3e4648932dbe1e823ac190560a3e507c7dd97ad2 Mon Sep 17 00:00:00 2001 From: Jamie King Date: Wed, 15 Jul 2026 21:26:41 -0600 Subject: [PATCH 10/10] feat(mxc-ocsf): clearer audit report + client-safe run-ocsf-audit.ps1 Improve the ETW to OCSF audit-trail example output and make it safe to ship. Report: - Add an event-type coverage count ("N of M expected event types fired"); the denominator auto-adjusts (8 with proxy on, 7 with -NoProxy). - Split the checklist into expected event types vs anomaly findings (ActivityError/FallbackError), which are reported separately and not counted toward coverage (a clean run may emit none). - Verdict is now coverage-based (all expected types must fire) instead of the looser "at least 3 OCSF classes". - Call out the absolute path to the durable OCSF JSONL log prominently. Client-safety: - Default -ShareOut to empty (no auto-copy); pass -ShareOut a UNC path to opt in. Removes a hardcoded internal share path from a published example. - Drop internal-team wording ("Hand that zip back for evaluation", "BUNDLE:") in favor of neutral "Results bundle:". - Update README-ocsf-audit.txt to match the opt-in -ShareOut behavior. Verified on both MXC boxes: 7F203-MXC-001 (base-container) -> PASS, 8 of 8 event types, 26 OCSF events across 4 classes; 7F203-MXC-003 (AppContainer fallback) -> reduced set as expected, clean output. Signed-off-by: Akber Raza --- .../examples/README-ocsf-audit.txt | 14 +-- .../examples/run-ocsf-audit.ps1 | 89 +++++++++++-------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt index 26d4af2ef3..ba960e42cf 100644 --- a/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt +++ b/crates/openshell-driver-mxc/examples/README-ocsf-audit.txt @@ -35,12 +35,13 @@ HOW TO RUN ... -File .\run-ocsf-audit.ps1 -WxcExecPath "D:\path\to\wxc-exec.exe" WHAT YOU GET BACK - The script prints PASS/FAIL + a class/event breakdown and creates: + The script prints PASS/FAIL + an event-type coverage count and class breakdown, + points you at the OCSF audit log, and creates: results-.zip - Hand that zip back. It contains the OCSF audit log (openshell-ocsf..log), - the full transcript, the gateway logs (with the human-readable OCSF shorthand), - a summary, and the exact config + policy used. The bundle is also auto-copied - to the shared drive for pickup (pass -ShareOut "" to disable that). + It contains the OCSF audit log (openshell-ocsf..log), the full transcript, + the gateway logs (with the human-readable OCSF shorthand), a summary, and the + exact config + policy used. To auto-copy the bundle to a shared location, pass + -ShareOut '\\server\share' (off by default; results stay local otherwise). FILES IN THIS PACKAGE openshell-gateway.exe the gateway (self-contained; needs only VC++ runtime) @@ -57,7 +58,8 @@ USEFUL OPTIONS SandboxProxyConfigured config event; everything else is still produced. (Default is proxy ON for the full set.) -WxcExecPath Path to wxc-exec.exe on this box. - -ShareOut "" Disable the auto-copy of the results bundle to the share. + -ShareOut Copy the results bundle to a shared location + (e.g. \\server\share). Off by default (results stay local). -KeepRunning Leave the gateway running afterward for inspection. NOTES diff --git a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 index 8c723a84e6..e36f995f4e 100644 --- a/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 +++ b/crates/openshell-driver-mxc/examples/run-ocsf-audit.ps1 @@ -26,7 +26,9 @@ # By default the per-sandbox egress proxy is ON so the full event set (including # SandboxProxyConfigured) is produced. Pass -NoProxy to omit only that one event. # -# Hand the produced results-*.zip back for evaluation. +# The deliverable is the OCSF audit log (openshell-ocsf..log) inside the +# results-*.zip the script produces. Pass -ShareOut '\\server\share' to also copy +# the bundle to a shared location (off by default). [CmdletBinding()] param( @@ -43,9 +45,9 @@ param( [string] $GatewayName = "openshell-mxc-ocsf", # Internal driver ETW session name (used to clean up a leaked session). [string] $SessionName = "OpenShell-MXC-ETW", - # Shared drive the results bundle is auto-copied to for pickup/analysis. - # Set to "" to disable the push. - [string] $ShareOut = "\\nvsw-dump\users\jamiek\prashant", + # Optional: copy the results bundle to this path (e.g. a shared drive) for + # pickup. Empty by default (no copy); pass -ShareOut '\\server\share' to enable. + [string] $ShareOut = "", # Leave the gateway running afterward (for inspection). [switch] $KeepRunning ) @@ -255,26 +257,40 @@ finally { $hosts = @($hosts | Select-Object -Unique) } - # Named-event checklist (detected from the human-readable shorthand lines). + # Event-type coverage (detected from the human-readable shorthand lines). function Seen([string]$pat) { [bool]($logText | Select-String -Pattern $pat -Quiet) } - $events = [ordered]@{ - "Sandbox created (lifecycle)" = Seen "(?i)ocsf:.*LIFECYCLE:" - "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" - "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" - "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" - "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" - "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" - "proxy configured" = Seen "(?i)ocsf:.*proxy configured" - "process launch (cmd line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" - "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" - "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" + + # Expected happy-path ETW->OCSF event types for THIS run. The egress-proxy + # event only fires when the proxy is enabled, so it only counts toward the + # expected total when -NoProxy was NOT passed. + $coreEvents = [ordered]@{ + "sandbox lifecycle (start)" = Seen "(?i)ocsf:.*LIFECYCLE:" + "OS policy enforced" = Seen "(?i)ocsf:.*OS policy enforced" + "OS policy configured" = Seen "(?i)ocsf:.*OS policy configured" + "win32k lockdown applied" = Seen "(?i)ocsf:.*win32k lockdown" + "UI restrictions applied" = Seen "(?i)ocsf:.*UI restrictions" + "console reference plumbed" = Seen "(?i)ocsf:.*console reference plumbed" + "process launch (command line)" = Seen "(?i)ocsf:.*PROC:LAUNCH" + } + if ($proxyOn) { $coreEvents["egress proxy configured"] = Seen "(?i)ocsf:.*proxy configured" } + + # Findings are anomaly / fallback signals - reported separately, NOT part of + # the expected-coverage denominator (a clean run may emit none). + $findingEvents = [ordered]@{ + "ActivityError finding" = Seen "(?i)ocsf:.*ActivityError" + "FallbackError finding" = Seen "(?i)ocsf:.*FallbackError" } - $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count - if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($classesSeen -ge 3) } - $verdict = if ($passed) { "PASS" } else { "FAIL" } - $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } - $eventLines = foreach ($k in $events.Keys) { " {0} {1}" -f $(if ($events[$k]) { "[x]" } else { "[ ]" }), $k } + $coreExpected = $coreEvents.Count + $coreObserved = @($coreEvents.Values | Where-Object { $_ }).Count + $findingsObserved = @($findingEvents.Values | Where-Object { $_ }).Count + $classesSeen = @($classCounts.Keys | Where-Object { $classCounts[$_] -gt 0 }).Count + if ($passed) { $passed = $consumerStarted -and ($jsonlCount -gt 0) -and ($jsonlBad -eq 0) -and ($coreObserved -eq $coreExpected) } + + $verdict = if ($passed) { "PASS" } else { "FAIL" } + $classLines = foreach ($uid in @(6002, 5019, 1007, 2004)) { " [{0}] {1,-28} : {2}" -f $uid, $classNames[$uid], $classCounts[$uid] } + $coreLines = foreach ($k in $coreEvents.Keys) { " {0} {1}" -f $(if ($coreEvents[$k]) { "[x]" } else { "[ ]" }), $k } + $findingLines = foreach ($k in $findingEvents.Keys) { " {0} {1}" -f $(if ($findingEvents[$k]) { "[x]" } else { "[ ]" }), $k } Step "RESULT" $summary = @" @@ -284,34 +300,38 @@ timestamp : $stamp machine : $env:COMPUTERNAME user : $env:USERNAME (admin=$admin perfLogUsers=$plu) verdict : $verdict -proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits SandboxProxyConfigured)' }) +event coverage : $coreObserved of $coreExpected expected event types fired (+ $findingsObserved anomaly finding(s)) +proxy : $(if ($proxyOn) { 'on (full event set)' } else { 'off (-NoProxy; omits egress proxy event)' }) wxc_exec : $WxcExecPath backend : process_container gateway_port : $Port sandboxes : $SandboxCount (distinct sandbox_ids in log: $($sids.Count)) -OCSF audit log (durable JSONL, one event per line): - file : $(if ($jsonlPath) { Split-Path $jsonlPath -Leaf } else { '(none written)' }) - events : $jsonlCount invalid-json: $jsonlBad host: $($hosts -join ',') +Event-type coverage - $coreObserved of $coreExpected expected event types fired: +$($coreLines -join "`r`n") + +Anomaly findings emitted (not counted toward coverage; a clean run may emit none): $findingsObserved +$($findingLines -join "`r`n") -OCSF classes captured: +OCSF events written : $jsonlCount total ($jsonlBad invalid-json) across $classesSeen OCSF class(es) $($classLines -join "`r`n") -Event coverage (from the human-readable shorthand): -$($eventLines -join "`r`n") +>> YOUR OCSF AUDIT LOG (the deliverable - durable JSONL, one OCSF event per line): + $(if ($jsonlPath) { $jsonlPath } else { '(none written - see gateway.log)' }) -Files in this bundle: - transcript.txt full console transcript - gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) +Files in this bundle ($resultDir): openshell-ocsf..log THE DELIVERABLE: durable OCSF audit trail (JSONL) summary.txt this summary + transcript.txt full console transcript + gateway.log / .err.log gateway stdout/stderr (OCSF shorthand lines live here) mxc-ocsf-audit.used.toml the exact gateway config used (wxc path patched) ocsf-audit.used.yaml the exact sandbox policy used What PASS means: the gateway launched sandbox(es), the in-process ETW consumer started, decoded the Sandboxing provider, attributed each event to a sandbox_id, -mapped them to OCSF, and wrote a durable JSONL audit log spanning $classesSeen event classes - -the full Windows OCSF path end-to-end, at parity with the Linux pipeline. +mapped them to OCSF, and wrote a durable JSONL audit log covering all $coreExpected +expected event types across $classesSeen OCSF class(es) - the full Windows OCSF path +end-to-end, at parity with the Linux pipeline. "@ Set-Content -Path (Join-Path $resultDir "summary.txt") -Value $summary -Encoding UTF8 Write-Host $summary -ForegroundColor ($(if ($passed) { "Green" } else { "Red" })) @@ -323,8 +343,7 @@ the full Windows OCSF path end-to-end, at parity with the Linux pipeline. $zip = Join-Path $here "results-$stamp.zip" if (Test-Path $zip) { Remove-Item $zip -Force } Compress-Archive -Path (Join-Path $resultDir "*") -DestinationPath $zip -Force - Write-Host "`nBUNDLE: $zip" -ForegroundColor Yellow - Write-Host "Hand that zip back for evaluation." -ForegroundColor Yellow + Write-Host "`nResults bundle: $zip" -ForegroundColor Yellow } catch { Write-Host "zip failed: $($_.Exception.Message)" -ForegroundColor Red } # Auto-push the bundle to the shared drive for pickup/analysis (skip if we