From 75611e2b9bff4edc4f5ceb358028ad23c63a1b7b Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 11:11:07 -0700 Subject: [PATCH 01/13] feat: decode WFP Learning Mode network events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_runner.rs | 1 + .../common/capture_output.rs | 1 + .../common/guarded_capture.rs | 2 + .../src/core/learning_mode_core/analyze.rs | 3 + .../src/core/learning_mode_core/emit.rs | 1 + .../src/core/learning_mode_core/mod.rs | 5 +- .../src/core/learning_mode_core/model.rs | 112 +++- .../learning_mode_core/verbose_logging.rs | 8 + .../learning_mode_windows/capability_dacl.rs | 1 + .../core/learning_mode_windows/etl_decode.rs | 181 ++++- .../core/learning_mode_windows/extractors.rs | 18 + .../src/core/learning_mode_windows/mod.rs | 2 + .../network_extractors.rs | 625 ++++++++++++++++++ src/mxc-sdk/src/core/plm/analysis.rs | 1 + src/mxc-sdk/src/core/plm/stop.rs | 1 + 15 files changed, 958 insertions(+), 4 deletions(-) create mode 100644 src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index 4ad7ec097..ed147f7ea 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -2372,6 +2372,7 @@ mod tests { access_type: AccessType::Read, pid: 42, filetime: 99, + details: None, }])), }; diff --git a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs index 909dab7c0..566141c15 100644 --- a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs +++ b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs @@ -297,6 +297,7 @@ mod tests { access_type: AccessType::Read, pid: 42, filetime: 99, + details: None, }]); let metadata = diff --git a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs index 37cc119cb..ec8c0775d 100644 --- a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs +++ b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs @@ -398,6 +398,7 @@ mod tests { access_type: AccessType::Read, pid: owner_pid, filetime: 1, + details: None, }]), })) } @@ -526,6 +527,7 @@ mod tests { access_type: AccessType::Read, pid: 4321, filetime: 7, + details: None, }]) } diff --git a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs index 47a9a04cc..de7b0b9bc 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs @@ -259,6 +259,7 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, + details: None, }]; let analyzer: Box = Box::new(FakeAnalyzer(denials.clone())); let got = analyzer.analyze(Path::new("ignored.etl")).unwrap(); @@ -324,6 +325,7 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, + details: None, }], denied_resources_truncated: false, verbose_logging: VerboseLoggingSummary { @@ -390,6 +392,7 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, + details: None, }], denied_resources_truncated: false, verbose_logging: VerboseLoggingSummary { diff --git a/src/mxc-sdk/src/core/learning_mode_core/emit.rs b/src/mxc-sdk/src/core/learning_mode_core/emit.rs index fd246f0d0..c4f8eb5a5 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/emit.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/emit.rs @@ -154,6 +154,7 @@ mod tests { access_type: AccessType::Read, pid: 100, filetime: 200, + details: None, } } diff --git a/src/mxc-sdk/src/core/learning_mode_core/mod.rs b/src/mxc-sdk/src/core/learning_mode_core/mod.rs index b090001dc..f418f177b 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/mod.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/mod.rs @@ -41,7 +41,10 @@ pub mod verbose_logging; pub use analyze::{AnalysisResult, AnalyzeError, DenialAnalyzer, ProcessLifetime}; pub use emit::{write_document, DenialsDocument, DenialsOutputPointer}; -pub use model::{AccessType, DedupKey, DeniedResource, ResourceType}; +pub use model::{ + AccessType, DedupKey, DenialDetails, DeniedResource, NetworkDenialDetails, NetworkDenialReason, + NetworkDenialSource, NetworkDirection, ResourceType, +}; pub use paired_output::{ relocate_output_file, relocate_paired_output_files, write_paired_output_files, ExistingOutputPolicy, RelocationOutcome, diff --git a/src/mxc-sdk/src/core/learning_mode_core/model.rs b/src/mxc-sdk/src/core/learning_mode_core/model.rs index 823afecea..cd3022f03 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/model.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/model.rs @@ -50,8 +50,7 @@ pub enum ResourceType { File, /// User-interface resource (clipboard, window handle, input, etc.). Ui, - /// Network endpoint. Reserved for future network/WFP capture; the - /// current Windows backend does not yet produce this variant. + /// Network endpoint reported by a supported backend source. Network, /// A named OS capability (AppContainer / brokered capability) the /// workload was denied. Capability records may be produced under either @@ -75,6 +74,76 @@ pub enum AccessType { Unknown, } +/// Source component that produced an actionable network denial. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkDenialSource { + /// Tessera/processmodel schema 0.8 WFP enforcement. + Tessera, +} + +/// Stable policy reason for an actionable network denial. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkDenialReason { + /// A direct-egress attempt matched Tessera's default-deny baseline. + DirectDefaultDeny, +} + +/// Direction of a denied network operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDirection { + /// Inbound traffic. + Inbound, + /// Outbound traffic. + Outbound, + /// Forwarded traffic. + Forward, + /// The source supplied an unrecognized direction value. + Unknown, +} + +/// Additional structured data carried by an actionable network denial. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetworkDenialDetails { + /// Enforcement component that produced the denial. + pub source: NetworkDenialSource, + /// Stable policy reason assigned by the source adapter. + pub reason: NetworkDenialReason, + /// Traffic direction reported by WFP. + pub direction: NetworkDirection, + /// IP protocol number when the source supplied one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, + /// Local numeric IP address when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_address: Option, + /// Local host-order port when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_port: Option, + /// Remote numeric IP address. + pub remote_address: String, + /// Remote host-order port when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_port: Option, + /// Application identifier supplied by the WFP NetEvent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub application_id: Option, + /// Runtime WFP filter identifier used to correlate the event to its live filter. + #[serde(with = "decimal_u64")] + pub filter_id: u64, +} + +/// Resource-family-specific metadata attached to a denial. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum DenialDetails { + /// Structured WFP network-denial metadata. + Network(NetworkDenialDetails), +} + /// One denied `(resource, accessType)` observation surfaced to consumers. /// /// A `DeniedResource` describes a single resource the sandboxed workload @@ -93,6 +162,7 @@ pub enum AccessType { /// access_type: AccessType::Read, /// pid: 1234, /// filetime: 132_847_890_123_456_789, +/// details: None, /// }; /// let json = serde_json::to_string(&denial)?; /// assert!(json.contains("\"resourceType\":\"file\"")); @@ -132,6 +202,11 @@ pub struct DeniedResource { /// JavaScript consumers do not lose precision. #[serde(with = "decimal_u64")] pub filetime: u64, + + /// Optional resource-family-specific metadata. Existing non-network + /// records omit this field, preserving their JSON shape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, } /// De-duplication key for a denial: the `(resource, accessType)` pair. @@ -166,6 +241,7 @@ mod tests { access_type: AccessType::Read, pid: 1234, filetime: 132_847_890_123_456_789, + details: None, }; let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("\"resource\":\"C:"), "got {json}"); @@ -185,6 +261,7 @@ mod tests { access_type: AccessType::Write, pid: 9999, filetime: 42, + details: None, }; let json = serde_json::to_string(&r).unwrap(); let parsed: DeniedResource = serde_json::from_str(&json).unwrap(); @@ -233,7 +310,38 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 1, + details: None, }; assert_eq!(r.dedup_key(), (r"C:\a".to_string(), AccessType::Read)); } + + #[test] + fn network_details_are_additive_and_preserve_u64_filter_id() { + let r = DeniedResource { + resource: "tcp://203.0.113.10:443".to_string(), + resource_type: ResourceType::Network, + access_type: AccessType::Unknown, + pid: 0, + filetime: 42, + details: Some(DenialDetails::Network(NetworkDenialDetails { + source: NetworkDenialSource::Tessera, + reason: NetworkDenialReason::DirectDefaultDeny, + direction: NetworkDirection::Outbound, + protocol: Some(6), + local_address: None, + local_port: None, + remote_address: "203.0.113.10".to_string(), + remote_port: Some(443), + application_id: Some(r"\device\harddiskvolume3\app.exe".to_string()), + filter_id: u64::MAX, + })), + }; + + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["details"]["kind"], "network"); + assert_eq!(json["details"]["filterId"], u64::MAX.to_string()); + assert_eq!(json["details"]["reason"], "directDefaultDeny"); + assert!(json["details"].get("localAddress").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), r); + } } diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index eaf366681..44ab1b646 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -61,6 +61,14 @@ pub enum VerboseLoggingOutcomeReason { ComInterfaceCall, /// The event was valid but did not describe an actionable denial. NotActionable, + /// The source reported an intentional explicit deny or allow-rule exclusion. + IntentionalNetworkPolicyDeny, + /// The source reported a direct connection blocked by proxy containment. + ProxyContainment, + /// The network event carried an unknown or unsupported stable reason. + UnknownNetworkReason, + /// The network event omitted endpoint data required for policy guidance. + IncompleteNetworkEndpoint, } impl VerboseLoggingOutcomeReason { diff --git a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs index 085dd4791..88280d590 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs @@ -269,6 +269,7 @@ pub(crate) fn extract_denials( object_name: name.to_string(), access_type: AccessType::Unknown, filetime, + details: None, event_id: parts.event_id, provider, verbose_logging_properties: diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 43fa86493..b64b5ac38 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -305,6 +305,7 @@ impl<'visitor> Accumulator<'visitor> { access_type: raw.access_type, pid: raw.pid, filetime: raw.filetime, + details: raw.details, }); } @@ -1110,7 +1111,10 @@ fn normalized_filetime(timestamp: i64, acc: &mut Accumulator<'_>) -> Option #[cfg(test)] mod tests { use super::*; - use crate::learning_mode_core::{AccessType, ResourceType}; + use crate::learning_mode_core::{ + AccessType, DenialDetails, NetworkDenialDetails, NetworkDenialReason, NetworkDenialSource, + NetworkDirection, ResourceType, + }; const SCOPED_PID: u32 = 42; const SCOPED_START_FILETIME: u64 = 100; @@ -2012,6 +2016,7 @@ mod tests { object_name: path.to_string(), access_type: access, filetime: 1, + details: None, event_id: 4907, provider: crate::learning_mode_core::VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, @@ -2147,6 +2152,7 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 1, + details: None, }) .collect(); accumulator.seen = (0..MAX_UNIQUE_DENIALS) @@ -2339,6 +2345,179 @@ mod tests { ) } + fn network_event(pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { + event_with_provider( + super::network_extractors::NETWORK_DECISION_PROVIDER, + super::network_extractors::NETWORK_DECISION_EVENT_ID, + pid, + filetime, + kv, + ) + } + + #[test] + fn managed_network_events_route_actionable_and_verbose_outcomes() { + let common = [ + ("SchemaVersion", "1"), + ("Mode", "1"), + ("NormalDecision", "1"), + ("EffectiveDecision", "1"), + ("OriginalTimestamp", "500"), + ("FilterId", "9001"), + ("Direction", "0"), + ]; + let app_isolation = common + .into_iter() + .chain([ + ("SourceDomain", "1"), + ("Reason", "1"), + ("FieldFlags", "64"), + ("SublayerGuid", "{FFE221C3-92A8-4564-A59F-DAFB70756020}"), + ("CapabilityId", "0"), + ]) + .collect::>(); + let tessera = common + .into_iter() + .chain([ + ("SourceDomain", "2"), + ("Reason", "100"), + ("FieldFlags", "51"), + ("ProviderGuid", "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"), + ("SublayerGuid", "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"), + ("ApplicationId", r"\Device\HarddiskVolume3\app.exe"), + ("Protocol", "6"), + ("RemoteAddress", "203.0.113.10"), + ("RemotePort", "443"), + ]) + .collect::>(); + let explicit_deny = common + .into_iter() + .chain([ + ("SourceDomain", "2"), + ("Reason", "101"), + ("FieldFlags", "0"), + ("ProviderGuid", "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"), + ("SublayerGuid", "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"), + ]) + .collect::>(); + + let events = [ + network_event(777, 600, &app_isolation), + network_event(777, 601, &tessera), + network_event(777, 602, &explicit_deny), + ]; + let analysis = resources_from_events(&events); + + assert_eq!(analysis.denials.len(), 2); + assert_eq!(analysis.denials[0].resource, "internetClient"); + assert_eq!(analysis.denials[0].resource_type, ResourceType::Capability); + assert_eq!(analysis.denials[0].pid, 0); + assert_eq!(analysis.denials[0].filetime, 500); + assert_eq!(analysis.denials[1].resource, "tcp://203.0.113.10:443"); + assert_eq!( + analysis.denials[1].details, + Some(DenialDetails::Network(NetworkDenialDetails { + source: NetworkDenialSource::Tessera, + reason: NetworkDenialReason::DirectDefaultDeny, + direction: NetworkDirection::Outbound, + protocol: Some(6), + local_address: None, + local_port: None, + remote_address: "203.0.113.10".to_string(), + remote_port: Some(443), + application_id: Some(r"\Device\HarddiskVolume3\app.exe".to_string()), + filter_id: 9001, + })) + ); + assert!(analysis.verbose_logging.signatures.iter().any(|aggregate| { + aggregate.signature.provider == VerboseLoggingProvider::LearningModeNetworkDecision + && aggregate.signature.reason + == VerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny + })); + } + + #[test] + fn managed_network_events_preserve_unknown_and_malformed_outcomes() { + let common = [ + ("SchemaVersion", "1"), + ("SourceDomain", "2"), + ("Mode", "1"), + ("NormalDecision", "1"), + ("EffectiveDecision", "1"), + ("OriginalTimestamp", "500"), + ("FilterId", "9001"), + ("Direction", "0"), + ("ProviderGuid", "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"), + ("SublayerGuid", "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"), + ("RemoteAddress", "203.0.113.10"), + ]; + let unknown_reason = common + .into_iter() + .chain([("Reason", "65535"), ("FieldFlags", "16")]) + .collect::>(); + let mut malformed = common + .into_iter() + .chain([("Reason", "100"), ("FieldFlags", "16")]) + .collect::>(); + malformed.retain(|(name, _)| *name != "Direction"); + + let analysis = resources_from_events(&[ + network_event(777, 600, &unknown_reason), + network_event(777, 601, &malformed), + ]); + + assert!(analysis.denials.is_empty()); + for expected in [ + VerboseLoggingOutcomeReason::UnknownNetworkReason, + VerboseLoggingOutcomeReason::EventPayloadMalformed, + ] { + let aggregate = analysis + .verbose_logging + .signatures + .iter() + .find(|aggregate| aggregate.signature.reason == expected) + .expect("base network event should remain in verbose diagnostics"); + assert!(aggregate + .signature + .properties + .iter() + .any(|(name, value)| name == "RemoteAddress" && value == "203.0.113.10")); + } + } + + #[test] + fn process_lifetime_scope_excludes_broker_emitted_network_events() { + let event = network_event( + 777, + 150, + &[ + ("SchemaVersion", "1"), + ("SourceDomain", "2"), + ("Mode", "1"), + ("NormalDecision", "1"), + ("EffectiveDecision", "1"), + ("Reason", "100"), + ("FieldFlags", "16"), + ("OriginalTimestamp", "150"), + ("FilterId", "9001"), + ("Direction", "0"), + ("ProviderGuid", "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"), + ("SublayerGuid", "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"), + ("RemoteAddress", "203.0.113.10"), + ], + ); + let lifetimes = [ProcessLifetime { + pid: 42, + start_filetime: 100, + end_filetime: 200, + }]; + + let analysis = resources_from_events_for_process_lifetimes(&[event], Some(&lifetimes)); + + assert!(analysis.denials.is_empty()); + assert!(analysis.verbose_logging.signatures.is_empty()); + } + /// Mirrors the real `Mode="Normal"` (`block`) capture: an actionable file /// check, a non-actionable registry check, and a compact capability denial. #[test] diff --git a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 145e59f61..655513c05 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -115,6 +115,8 @@ pub struct RawDenial { pub access_type: AccessType, /// Kernel `FILETIME` of the event. pub filetime: u64, + /// Optional resource-family-specific metadata. + pub details: Option, /// Originating ETW event ID (kept for diagnostics). pub event_id: u16, /// Symbolic category of the originating provider, for verbose logging @@ -146,6 +148,9 @@ pub fn extract_denial( if !is_learning_mode_event(parts.provider, parts.event_id) { return Err(VerboseLoggingOutcomeReason::UnsupportedEventSchema); } + if parts.provider == super::network_extractors::NETWORK_DECISION_PROVIDER { + return super::network_extractors::extract_network_denial(parts); + } match parts.event_id { ACCESS_CHECK_EVENT_ID | PRIVACY_ACCESS_CHECK_EVENT_ID => { @@ -167,6 +172,10 @@ pub fn extract_denial( pub(crate) fn verbose_logging_classification( parts: &DecodedEventParts, ) -> (Option, Option) { + if parts.provider == super::network_extractors::NETWORK_DECISION_PROVIDER { + return super::network_extractors::verbose_logging_classification(parts); + } + match parts.event_id { ACCESS_CHECK_EVENT_ID | PRIVACY_ACCESS_CHECK_EVENT_ID => { let Some(object_type) = find_prop(&parts.props, "ObjectType") else { @@ -245,6 +254,9 @@ pub(crate) fn is_process_scoped_event(provider: GUID, event_id: u16) -> bool { pub(crate) fn effective_event_pid(parts: &DecodedEventParts, header_pid: u32) -> Option { if parts.provider == NETWORK_DECISION_PROVIDER { + // Public WFP NetEvents carry package/application identity but no + // reliable workload PID. PID 0 is the explicit wire sentinel for + // unavailable process identity. Some(0) } else if parts.event_id == CAPABILITY_DENIAL_EVENT_ID { effective_capability_event_pid( @@ -434,6 +446,9 @@ fn is_identity_property(name: &str) -> bool { fn looks_like_file_path_property(name: &str, value: &str, object_type: Option<&str>) -> bool { let normalized = NormalizedPropertyName(name); + if normalized.equals("applicationid") { + return true; + } if normalized.ends_with("path") || normalized.ends_with("filename") || normalized.ends_with("filenamestring") @@ -777,6 +792,7 @@ pub fn build_denial_from_access_check( object_name, access_type, filetime, + details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), @@ -867,6 +883,7 @@ pub fn build_denial_from_learning_mode( object_name, access_type: AccessType::Unknown, filetime, + details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), @@ -934,6 +951,7 @@ pub fn build_denial_from_capability( object_name, access_type: AccessType::Unknown, filetime, + details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), diff --git a/src/mxc-sdk/src/core/learning_mode_windows/mod.rs b/src/mxc-sdk/src/core/learning_mode_windows/mod.rs index b1cba1f7b..a51468833 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/mod.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/mod.rs @@ -50,6 +50,8 @@ mod extractors; #[cfg(target_os = "windows")] mod ffi; #[cfg(target_os = "windows")] +mod network_extractors; +#[cfg(target_os = "windows")] mod path_norm; #[cfg(target_os = "windows")] mod process_lifetime; diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs new file mode 100644 index 000000000..f2a49cc7c --- /dev/null +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -0,0 +1,625 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Extractors for the OS-managed Learning Mode WFP decision event. + +use crate::learning_mode_core::{ + AccessType, DenialDetails, NetworkDenialDetails, NetworkDenialReason, NetworkDenialSource, + NetworkDirection, ResourceType, VerboseLoggingOutcomeReason as VerboseLoggingExclusionReason, + VerboseLoggingProvider, +}; +use std::net::IpAddr; +use windows::core::GUID; + +use super::extractors::{sanitize_properties, DecodedEventParts, RawDenial}; + +pub(crate) const NETWORK_DECISION_PROVIDER: GUID = GUID { + data1: 0x7123_7669, + data2: 0x21c3, + data3: 0x4101, + data4: [0xbd, 0x2f, 0xff, 0x38, 0x94, 0x5d, 0x72, 0x5a], +}; +pub(crate) const NETWORK_DECISION_EVENT_ID: u16 = 1; + +const SCHEMA_VERSION_V1: u16 = 1; +const SOURCE_APP_ISOLATION: u8 = 1; +const SOURCE_TESSERA: u8 = 2; +const MODE_LEARNING: u8 = 1; +const DECISION_DENY: u8 = 1; + +const REASON_APP_ISOLATION_MISSING_CAPABILITY: u16 = 1; +const REASON_TESSERA_DIRECT_DEFAULT_DENY: u16 = 100; +const REASON_TESSERA_EXPLICIT_DENY: u16 = 101; +const REASON_TESSERA_ALLOW_EXCLUSION: u16 = 102; +const REASON_TESSERA_PROXY_CONTAINMENT: u16 = 103; + +const FIELD_APPLICATION_ID: u32 = 1 << 0; +const FIELD_PROTOCOL: u32 = 1 << 1; +const FIELD_LOCAL_ADDRESS: u32 = 1 << 2; +const FIELD_LOCAL_PORT: u32 = 1 << 3; +const FIELD_REMOTE_ADDRESS: u32 = 1 << 4; +const FIELD_REMOTE_PORT: u32 = 1 << 5; +const FIELD_CAPABILITY_ID: u32 = 1 << 6; + +const TESSERA_PROVIDER: &str = "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"; +const TESSERA_SUBLAYER: &str = "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"; +const APP_ISOLATION_SUBLAYER: &str = "{FFE221C3-92A8-4564-A59F-DAFB70756020}"; + +pub(crate) fn extract_network_denial( + parts: &DecodedEventParts, +) -> Result { + if parts.provider != NETWORK_DECISION_PROVIDER || parts.event_id != NETWORK_DECISION_EVENT_ID { + return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + } + + let schema_version = required_u16(parts, "SchemaVersion")?; + let source_domain = required_u8(parts, "SourceDomain")?; + let mode = required_u8(parts, "Mode")?; + let normal_decision = required_u8(parts, "NormalDecision")?; + let effective_decision = required_u8(parts, "EffectiveDecision")?; + let reason = required_u16(parts, "Reason")?; + let field_flags = required_u32(parts, "FieldFlags")?; + let filetime = required_u64(parts, "OriginalTimestamp")?; + let filter_id = required_u64(parts, "FilterId")?; + + if schema_version != SCHEMA_VERSION_V1 + || mode != MODE_LEARNING + || normal_decision != DECISION_DENY + || effective_decision != DECISION_DENY + || filetime == 0 + || filter_id == 0 + { + return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + } + + match source_domain { + SOURCE_APP_ISOLATION => { + extract_app_isolation(parts, reason, field_flags, filetime, filter_id) + } + SOURCE_TESSERA => extract_tessera(parts, reason, field_flags, filetime, filter_id), + _ => Err(VerboseLoggingExclusionReason::UnknownNetworkReason), + } +} + +pub(crate) fn verbose_logging_classification( + parts: &DecodedEventParts, +) -> (Option, Option) { + let source = property(parts, "SourceDomain").and_then(parse_u8); + let reason = property(parts, "Reason").and_then(parse_u16); + match (source, reason) { + (Some(SOURCE_APP_ISOLATION), Some(REASON_APP_ISOLATION_MISSING_CAPABILITY)) => { + (Some(AccessType::Unknown), Some(ResourceType::Capability)) + } + (Some(SOURCE_TESSERA), _) => (Some(AccessType::Unknown), Some(ResourceType::Network)), + _ => (None, None), + } +} + +fn extract_app_isolation( + parts: &DecodedEventParts, + reason: u16, + field_flags: u32, + filetime: u64, + _filter_id: u64, +) -> Result { + if !property_eq(parts, "SublayerGuid", APP_ISOLATION_SUBLAYER) { + return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + } + if reason != REASON_APP_ISOLATION_MISSING_CAPABILITY { + return Err(VerboseLoggingExclusionReason::UnknownNetworkReason); + } + if field_flags & FIELD_CAPABILITY_ID == 0 { + return Err(VerboseLoggingExclusionReason::UnresolvedCapability); + } + let capability = match required_u32(parts, "CapabilityId")? { + 0 => "internetClient", + 1 => "internetClientServer", + 2 => "privateNetworkClientServer", + _ => return Err(VerboseLoggingExclusionReason::UnresolvedCapability), + }; + + Ok(RawDenial { + pid: 0, + resource_type: ResourceType::Capability, + object_name: capability.to_string(), + access_type: AccessType::Unknown, + filetime, + details: None, + event_id: parts.event_id, + provider: VerboseLoggingProvider::LearningModeNetworkDecision, + verbose_logging_properties: sanitize_properties(&parts.props), + }) +} + +fn extract_tessera( + parts: &DecodedEventParts, + reason: u16, + field_flags: u32, + filetime: u64, + filter_id: u64, +) -> Result { + if !property_eq(parts, "ProviderGuid", TESSERA_PROVIDER) + || !property_eq(parts, "SublayerGuid", TESSERA_SUBLAYER) + || field_flags & FIELD_CAPABILITY_ID != 0 + { + return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + } + + match reason { + REASON_TESSERA_EXPLICIT_DENY | REASON_TESSERA_ALLOW_EXCLUSION => { + return Err(VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny) + } + REASON_TESSERA_PROXY_CONTAINMENT => { + return Err(VerboseLoggingExclusionReason::ProxyContainment) + } + REASON_TESSERA_DIRECT_DEFAULT_DENY => {} + _ => return Err(VerboseLoggingExclusionReason::UnknownNetworkReason), + } + + if field_flags & FIELD_REMOTE_ADDRESS == 0 { + return Err(VerboseLoggingExclusionReason::IncompleteNetworkEndpoint); + } + + let remote_address = required_ip_string(parts, "RemoteAddress")?; + + let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; + let local_address = + optional_ip_string(parts, field_flags, FIELD_LOCAL_ADDRESS, "LocalAddress")?; + let local_port = optional_u16(parts, field_flags, FIELD_LOCAL_PORT, "LocalPort")?; + let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; + let application_id = + optional_string(parts, field_flags, FIELD_APPLICATION_ID, "ApplicationId")?; + let direction = required_u32(parts, "Direction").map(network_direction)?; + let resource = format_network_resource(protocol, &remote_address, remote_port); + + Ok(RawDenial { + pid: 0, + resource_type: ResourceType::Network, + object_name: resource, + access_type: AccessType::Unknown, + filetime, + details: Some(DenialDetails::Network(NetworkDenialDetails { + source: NetworkDenialSource::Tessera, + reason: NetworkDenialReason::DirectDefaultDeny, + direction, + protocol, + local_address, + local_port, + remote_address, + remote_port, + application_id, + filter_id, + })), + event_id: parts.event_id, + provider: VerboseLoggingProvider::LearningModeNetworkDecision, + verbose_logging_properties: sanitize_properties(&parts.props), + }) +} + +fn network_direction(value: u32) -> NetworkDirection { + match value { + 0 => NetworkDirection::Outbound, + 1 => NetworkDirection::Inbound, + _ => NetworkDirection::Unknown, + } +} + +fn format_network_resource(protocol: Option, address: &str, port: Option) -> String { + let scheme = match protocol { + Some(6) => "tcp".to_string(), + Some(17) => "udp".to_string(), + Some(1) => "icmp".to_string(), + Some(58) => "icmpv6".to_string(), + Some(value) => format!("ip-{value}"), + None => "ip".to_string(), + }; + let host = if address.contains(':') { + format!("[{address}]") + } else { + address.to_string() + }; + port.map_or_else( + || format!("{scheme}://{host}"), + |port| format!("{scheme}://{host}:{port}"), + ) +} + +fn property<'a>(parts: &'a DecodedEventParts, name: &str) -> Option<&'a str> { + parts + .props + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim().trim_matches('"')) +} + +fn property_eq(parts: &DecodedEventParts, name: &str, expected: &str) -> bool { + property(parts, name).is_some_and(|value| value.eq_ignore_ascii_case(expected)) +} + +fn required_string( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .map(ToOwned::to_owned) + .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) +} + +fn required_ip_string( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + required_string(parts, name)? + .parse::() + .map(|address| address.to_string()) + .map_err(|_| VerboseLoggingExclusionReason::IncompleteNetworkEndpoint) +} + +fn required_u8( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u8) + .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) +} + +fn required_u16( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u16) + .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) +} + +fn required_u32( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u32) + .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) +} + +fn required_u64( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u64) + .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) +} + +fn optional_string( + parts: &DecodedEventParts, + flags: u32, + flag: u32, + name: &'static str, +) -> Result, VerboseLoggingExclusionReason> { + if flags & flag == 0 { + return Ok(None); + } + required_string(parts, name).map(Some) +} + +fn optional_ip_string( + parts: &DecodedEventParts, + flags: u32, + flag: u32, + name: &'static str, +) -> Result, VerboseLoggingExclusionReason> { + if flags & flag == 0 { + return Ok(None); + } + required_ip_string(parts, name).map(Some) +} + +fn optional_u8( + parts: &DecodedEventParts, + flags: u32, + flag: u32, + name: &'static str, +) -> Result, VerboseLoggingExclusionReason> { + if flags & flag == 0 { + return Ok(None); + } + required_u8(parts, name).map(Some) +} + +fn optional_u16( + parts: &DecodedEventParts, + flags: u32, + flag: u32, + name: &'static str, +) -> Result, VerboseLoggingExclusionReason> { + if flags & flag == 0 { + return Ok(None); + } + required_u16(parts, name).map(Some) +} + +fn parse_u8(value: &str) -> Option { + parse_u64(value).and_then(|value| u8::try_from(value).ok()) +} + +fn parse_u16(value: &str) -> Option { + parse_u64(value).and_then(|value| u16::try_from(value).ok()) +} + +fn parse_u32(value: &str) -> Option { + parse_u64(value).and_then(|value| u32::try_from(value).ok()) +} + +fn parse_u64(value: &str) -> Option { + let value = value.trim().trim_matches('"').trim(); + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .map_or_else( + || value.parse::().ok(), + |hex| u64::from_str_radix(hex, 16).ok(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(source: u8, reason: u16, fields: &[(&str, &str)]) -> DecodedEventParts { + let mut props = vec![ + ("SchemaVersion".to_string(), "1".to_string()), + ("SourceDomain".to_string(), source.to_string()), + ("Mode".to_string(), "1".to_string()), + ("NormalDecision".to_string(), "1".to_string()), + ("EffectiveDecision".to_string(), "1".to_string()), + ("Reason".to_string(), reason.to_string()), + ("FieldFlags".to_string(), "0".to_string()), + ("OriginalTimestamp".to_string(), "123".to_string()), + ("UserSid".to_string(), "S-1-5-21-1".to_string()), + ("PackageSid".to_string(), "S-1-15-2-1".to_string()), + ("ApplicationId".to_string(), String::new()), + ("WfpEventType".to_string(), "0".to_string()), + ("FilterId".to_string(), "456".to_string()), + ("ProviderGuid".to_string(), TESSERA_PROVIDER.to_string()), + ("SublayerGuid".to_string(), TESSERA_SUBLAYER.to_string()), + ("LayerId".to_string(), "0".to_string()), + ("Direction".to_string(), "0".to_string()), + ("IsLoopback".to_string(), "0".to_string()), + ("Protocol".to_string(), "0".to_string()), + ("LocalAddress".to_string(), String::new()), + ("LocalPort".to_string(), "0".to_string()), + ("RemoteAddress".to_string(), String::new()), + ("RemotePort".to_string(), "0".to_string()), + ("CapabilityId".to_string(), "0".to_string()), + ]; + for (name, value) in fields { + if let Some((_, current)) = props.iter_mut().find(|(candidate, _)| candidate == name) { + *current = (*value).to_string(); + } else { + props.push(((*name).to_string(), (*value).to_string())); + } + } + DecodedEventParts { + provider: NETWORK_DECISION_PROVIDER, + event_id: NETWORK_DECISION_EVENT_ID, + props, + } + } + + fn replace(parts: &mut DecodedEventParts, name: &str, value: impl Into) { + let (_, current) = parts + .props + .iter_mut() + .find(|(candidate, _)| candidate == name) + .unwrap(); + *current = value.into(); + } + + #[test] + fn app_isolation_capability_drop_maps_known_capability() { + let mut parts = event( + SOURCE_APP_ISOLATION, + REASON_APP_ISOLATION_MISSING_CAPABILITY, + &[("CapabilityId", "0")], + ); + replace( + &mut parts, + "SublayerGuid", + APP_ISOLATION_SUBLAYER.to_string(), + ); + replace(&mut parts, "FieldFlags", FIELD_CAPABILITY_ID.to_string()); + + let denial = extract_network_denial(&parts).unwrap(); + assert_eq!(denial.object_name, "internetClient"); + assert_eq!(denial.resource_type, ResourceType::Capability); + assert_eq!(denial.pid, 0); + assert_eq!(denial.filetime, 123); + } + + #[test] + fn tessera_direct_default_deny_preserves_network_details() { + let flags = + FIELD_APPLICATION_ID | FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("ApplicationId", r"\Device\HarddiskVolume3\app.exe"), + ("Protocol", "6"), + ("RemoteAddress", "203.0.113.10"), + ("RemotePort", "443"), + ], + ); + replace(&mut parts, "FieldFlags", flags.to_string()); + + let denial = extract_network_denial(&parts).unwrap(); + assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); + assert_eq!(denial.resource_type, ResourceType::Network); + assert!(matches!( + denial.details, + Some(DenialDetails::Network(NetworkDenialDetails { + direction: NetworkDirection::Outbound, + protocol: Some(6), + remote_port: Some(443), + filter_id: 456, + .. + })) + )); + } + + #[test] + fn tessera_ipv6_resource_uses_brackets() { + let flags = FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("Protocol", "17"), + ("RemoteAddress", "2001:db8::1"), + ("RemotePort", "53"), + ], + ); + replace(&mut parts, "FieldFlags", flags.to_string()); + + assert_eq!( + extract_network_denial(&parts).unwrap().object_name, + "udp://[2001:db8::1]:53" + ); + } + + #[test] + fn intentional_and_proxy_denials_are_verbose_only() { + for (reason, expected) in [ + ( + REASON_TESSERA_EXPLICIT_DENY, + VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny, + ), + ( + REASON_TESSERA_ALLOW_EXCLUSION, + VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny, + ), + ( + REASON_TESSERA_PROXY_CONTAINMENT, + VerboseLoggingExclusionReason::ProxyContainment, + ), + ] { + let parts = event(SOURCE_TESSERA, reason, &[]); + assert_eq!(extract_network_denial(&parts).unwrap_err(), expected); + } + } + + #[test] + fn tessera_default_deny_requires_remote_address() { + let parts = event(SOURCE_TESSERA, REASON_TESSERA_DIRECT_DEFAULT_DENY, &[]); + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::IncompleteNetworkEndpoint + ); + } + + #[test] + fn tessera_default_deny_requires_numeric_addresses() { + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[("RemoteAddress", "example.com")], + ); + replace(&mut parts, "FieldFlags", FIELD_REMOTE_ADDRESS.to_string()); + + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::IncompleteNetworkEndpoint + ); + } + + #[test] + fn future_fields_and_flags_do_not_hide_a_valid_v1_event() { + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[("RemoteAddress", "203.0.113.10")], + ); + replace( + &mut parts, + "FieldFlags", + (FIELD_REMOTE_ADDRESS | (1 << 31)).to_string(), + ); + parts + .props + .push(("FutureTrailingProperty".to_string(), "future".to_string())); + + assert_eq!( + extract_network_denial(&parts).unwrap().object_name, + "ip://203.0.113.10" + ); + } + + #[test] + fn source_identity_mismatch_fails_closed() { + let mut parts = event(SOURCE_TESSERA, REASON_TESSERA_DIRECT_DEFAULT_DENY, &[]); + replace( + &mut parts, + "ProviderGuid", + "{00000000-0000-0000-0000-000000000000}", + ); + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnsupportedEventSchema + ); + } + + #[test] + fn shipped_v1_payload_uses_the_original_24_fields() { + let parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[("RemoteAddress", "203.0.113.10")], + ); + + let names = parts + .props + .iter() + .map(|(name, _)| name.as_str()) + .collect::>(); + assert_eq!( + names, + [ + "SchemaVersion", + "SourceDomain", + "Mode", + "NormalDecision", + "EffectiveDecision", + "Reason", + "FieldFlags", + "OriginalTimestamp", + "UserSid", + "PackageSid", + "ApplicationId", + "WfpEventType", + "FilterId", + "ProviderGuid", + "SublayerGuid", + "LayerId", + "Direction", + "IsLoopback", + "Protocol", + "LocalAddress", + "LocalPort", + "RemoteAddress", + "RemotePort", + "CapabilityId", + ] + ); + } + + #[test] + fn unknown_stable_reason_remains_verbose_only() { + let parts = event(SOURCE_TESSERA, u16::MAX, &[]); + + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnknownNetworkReason + ); + } +} diff --git a/src/mxc-sdk/src/core/plm/analysis.rs b/src/mxc-sdk/src/core/plm/analysis.rs index 59054820e..7ff5769c9 100644 --- a/src/mxc-sdk/src/core/plm/analysis.rs +++ b/src/mxc-sdk/src/core/plm/analysis.rs @@ -194,6 +194,7 @@ mod tests { access_type, pid: 42, filetime: 1, + details: None, } } diff --git a/src/mxc-sdk/src/core/plm/stop.rs b/src/mxc-sdk/src/core/plm/stop.rs index 42c4cad5f..a6e61d609 100644 --- a/src/mxc-sdk/src/core/plm/stop.rs +++ b/src/mxc-sdk/src/core/plm/stop.rs @@ -510,6 +510,7 @@ mod tests { access_type: AccessType::Unknown, pid: 42, filetime: 1, + details: None, }], DenialSummary::new(7, 1, false), ); From cf21fa5acc3b29b204edb5029a4ec214f6c7cca1 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 11:44:51 -0700 Subject: [PATCH 02/13] refactor: use product-facing network denial source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- src/mxc-sdk/src/core/learning_mode_core/model.rs | 7 ++++--- src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs | 2 +- .../src/core/learning_mode_windows/network_extractors.rs | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_core/model.rs b/src/mxc-sdk/src/core/learning_mode_core/model.rs index cd3022f03..a6627eb17 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/model.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/model.rs @@ -78,8 +78,8 @@ pub enum AccessType { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NetworkDenialSource { - /// Tessera/processmodel schema 0.8 WFP enforcement. - Tessera, + /// ProcessContainer schema 0.8 network-policy enforcement. + ProcessContainerNetworkPolicy, } /// Stable policy reason for an actionable network denial. @@ -324,7 +324,7 @@ mod tests { pid: 0, filetime: 42, details: Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::Tessera, + source: NetworkDenialSource::ProcessContainerNetworkPolicy, reason: NetworkDenialReason::DirectDefaultDeny, direction: NetworkDirection::Outbound, protocol: Some(6), @@ -339,6 +339,7 @@ mod tests { let json = serde_json::to_value(&r).unwrap(); assert_eq!(json["details"]["kind"], "network"); + assert_eq!(json["details"]["source"], "processContainerNetworkPolicy"); assert_eq!(json["details"]["filterId"], u64::MAX.to_string()); assert_eq!(json["details"]["reason"], "directDefaultDeny"); assert!(json["details"].get("localAddress").is_none()); diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index b64b5ac38..14e5bf5f1 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -2417,7 +2417,7 @@ mod tests { assert_eq!( analysis.denials[1].details, Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::Tessera, + source: NetworkDenialSource::ProcessContainerNetworkPolicy, reason: NetworkDenialReason::DirectDefaultDeny, direction: NetworkDirection::Outbound, protocol: Some(6), diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index f2a49cc7c..74f7d9c83 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -179,7 +179,7 @@ fn extract_tessera( access_type: AccessType::Unknown, filetime, details: Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::Tessera, + source: NetworkDenialSource::ProcessContainerNetworkPolicy, reason: NetworkDenialReason::DirectDefaultDeny, direction, protocol, From 9078b7a607c17f381a0870f633205d62c3e072bd Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Mon, 5 Oct 2026 16:49:42 -0700 Subject: [PATCH 03/13] test: harden WFP network decoding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../src/core/learning_mode_core/model.rs | 16 ++- .../network_extractors.rs | 68 ++++++++-- .../core/learning_mode_windows/tdh_decode.rs | 117 ++++++++++++++++-- 3 files changed, 183 insertions(+), 18 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_core/model.rs b/src/mxc-sdk/src/core/learning_mode_core/model.rs index a6627eb17..a4c2e157d 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/model.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/model.rs @@ -180,7 +180,9 @@ pub struct DeniedResource { /// capability name (e.g. `internetClient`), resolved from the /// capability SID; unresolved custom capabilities fall back to the /// `S-1-15-3-…` SID string. - /// - [`Network`](ResourceType::Network) (when implemented): `host:port`. + /// - [`Network`](ResourceType::Network): `protocol://host[:port]`, with + /// IPv6 hosts enclosed in brackets (for example, + /// `tcp://[2001:db8::1]:443`). /// - [`Ui`](ResourceType::Ui) / [`Other`](ResourceType::Other): the raw /// resource identifier the source event carried (may be empty). pub resource: String, @@ -302,6 +304,18 @@ mod tests { } } + #[test] + fn network_direction_serialises_each_variant_to_lowercase() { + for (variant, expected) in [ + (NetworkDirection::Inbound, "\"inbound\""), + (NetworkDirection::Outbound, "\"outbound\""), + (NetworkDirection::Forward, "\"forward\""), + (NetworkDirection::Unknown, "\"unknown\""), + ] { + assert_eq!(serde_json::to_string(&variant).unwrap(), expected); + } + } + #[test] fn dedup_key_pairs_path_and_access_type() { let r = DeniedResource { diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 74f7d9c83..752b768ff 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -417,11 +417,38 @@ mod tests { } #[test] - fn app_isolation_capability_drop_maps_known_capability() { + fn app_isolation_capability_ids_map_to_stable_names() { + for (capability_id, expected) in [ + (0, "internetClient"), + (1, "internetClientServer"), + (2, "privateNetworkClientServer"), + ] { + let mut parts = event( + SOURCE_APP_ISOLATION, + REASON_APP_ISOLATION_MISSING_CAPABILITY, + &[("CapabilityId", &capability_id.to_string())], + ); + replace( + &mut parts, + "SublayerGuid", + APP_ISOLATION_SUBLAYER.to_string(), + ); + replace(&mut parts, "FieldFlags", FIELD_CAPABILITY_ID.to_string()); + + let denial = extract_network_denial(&parts).unwrap(); + assert_eq!(denial.object_name, expected); + assert_eq!(denial.resource_type, ResourceType::Capability); + assert_eq!(denial.pid, 0); + assert_eq!(denial.filetime, 123); + } + } + + #[test] + fn app_isolation_unknown_capability_is_verbose_only() { let mut parts = event( SOURCE_APP_ISOLATION, REASON_APP_ISOLATION_MISSING_CAPABILITY, - &[("CapabilityId", "0")], + &[("CapabilityId", "3")], ); replace( &mut parts, @@ -430,11 +457,10 @@ mod tests { ); replace(&mut parts, "FieldFlags", FIELD_CAPABILITY_ID.to_string()); - let denial = extract_network_denial(&parts).unwrap(); - assert_eq!(denial.object_name, "internetClient"); - assert_eq!(denial.resource_type, ResourceType::Capability); - assert_eq!(denial.pid, 0); - assert_eq!(denial.filetime, 123); + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnresolvedCapability + ); } #[test] @@ -468,6 +494,34 @@ mod tests { )); } + #[test] + fn tessera_direction_values_map_to_stable_variants() { + for (direction, expected) in [ + (0, NetworkDirection::Outbound), + (1, NetworkDirection::Inbound), + (u32::MAX, NetworkDirection::Unknown), + ] { + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("Direction", &direction.to_string()), + ("RemoteAddress", "203.0.113.10"), + ], + ); + replace(&mut parts, "FieldFlags", FIELD_REMOTE_ADDRESS.to_string()); + + let denial = extract_network_denial(&parts).unwrap(); + assert!(matches!( + denial.details, + Some(DenialDetails::Network(NetworkDenialDetails { + direction: actual, + .. + })) if actual == expected + )); + } + } + #[test] fn tessera_ipv6_resource_uses_brackets() { let flags = FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; diff --git a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs index ce31c29d1..a5a6c450d 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs @@ -1042,14 +1042,22 @@ mod tests { } pub(super) fn uint32_property_buffer(names: &[&str]) -> TdhInfoBuffer { - assert!(!names.is_empty()); - let encoded_names = names + let definitions = names .iter() - .map(|name| utf16_bytes(name)) + .map(|name| (*name, TDH_INTYPE_UINT32, 4)) + .collect::>(); + property_buffer(&definitions) + } + + fn property_buffer(definitions: &[(&str, u16, u16)]) -> TdhInfoBuffer { + assert!(!definitions.is_empty()); + let encoded_names = definitions + .iter() + .map(|(name, _, _)| utf16_bytes(name)) .collect::>(); let metadata_len = std::mem::size_of::() - + (names.len() - 1) * std::mem::size_of::(); - let mut offsets = Vec::with_capacity(names.len()); + + (definitions.len() - 1) * std::mem::size_of::(); + let mut offsets = Vec::with_capacity(definitions.len()); let mut next_offset = metadata_len; for name in &encoded_names { offsets.push(next_offset); @@ -1061,20 +1069,24 @@ mod tests { buffer.as_bytes_mut()[*offset..*offset + name.len()].copy_from_slice(name); } let info = unsafe { &mut *buffer.as_mut_ptr() }; - info.PropertyCount = names.len() as u32; - info.TopLevelPropertyCount = names.len() as u32; + info.PropertyCount = definitions.len() as u32; + info.TopLevelPropertyCount = definitions.len() as u32; let properties = std::ptr::addr_of_mut!(info.EventPropertyInfoArray) as *mut EVENT_PROPERTY_INFO; - for (index, offset) in offsets.into_iter().enumerate() { + for (index, ((_, in_type, length), offset)) in definitions.iter().zip(offsets).enumerate() { let property = unsafe { &mut *properties.add(index) }; property.NameOffset = offset as u32; - property.Anonymous1.nonStructType.InType = TDH_INTYPE_UINT32; + property.Anonymous1.nonStructType.InType = *in_type; property.Anonymous2.count = 1; - property.Anonymous3.length = 4; + property.Anonymous3.length = *length; } buffer } + fn push_unicode(payload: &mut Vec, value: &str) { + payload.extend(utf16_bytes(value)); + } + fn event_record_for_payload(payload: &[u8]) -> EVENT_RECORD { let mut record: EVENT_RECORD = unsafe { core::mem::zeroed() }; record.UserData = payload.as_ptr().cast_mut().cast(); @@ -1082,6 +1094,91 @@ mod tests { record } + #[test] + fn shipped_network_v1_tdh_rendering_reaches_network_extractor() { + let definitions = [ + ("SchemaVersion", TDH_INTYPE_UINT16, 2), + ("SourceDomain", TDH_INTYPE_UINT8, 1), + ("Mode", TDH_INTYPE_UINT8, 1), + ("NormalDecision", TDH_INTYPE_UINT8, 1), + ("EffectiveDecision", TDH_INTYPE_UINT8, 1), + ("Reason", TDH_INTYPE_UINT16, 2), + ("FieldFlags", TDH_INTYPE_UINT32, 4), + ("OriginalTimestamp", TDH_INTYPE_UINT64, 8), + ("UserSid", TDH_INTYPE_UNICODESTRING, 0), + ("PackageSid", TDH_INTYPE_UNICODESTRING, 0), + ("ApplicationId", TDH_INTYPE_UNICODESTRING, 0), + ("WfpEventType", TDH_INTYPE_UINT32, 4), + ("FilterId", TDH_INTYPE_UINT64, 8), + ("ProviderGuid", TDH_INTYPE_UNICODESTRING, 0), + ("SublayerGuid", TDH_INTYPE_UNICODESTRING, 0), + ("LayerId", TDH_INTYPE_UINT16, 2), + ("Direction", TDH_INTYPE_UINT32, 4), + ("IsLoopback", TDH_INTYPE_UINT8, 1), + ("Protocol", TDH_INTYPE_UINT8, 1), + ("LocalAddress", TDH_INTYPE_UNICODESTRING, 0), + ("LocalPort", TDH_INTYPE_UINT16, 2), + ("RemoteAddress", TDH_INTYPE_UNICODESTRING, 0), + ("RemotePort", TDH_INTYPE_UINT16, 2), + ("CapabilityId", TDH_INTYPE_UINT32, 4), + ]; + let buffer = property_buffer(&definitions); + let mut payload = Vec::new(); + payload.extend(1u16.to_le_bytes()); + payload.push(2); + payload.push(1); + payload.push(1); + payload.push(1); + payload.extend(100u16.to_le_bytes()); + payload.extend(51u32.to_le_bytes()); + payload.extend(123u64.to_le_bytes()); + push_unicode(&mut payload, "S-1-5-21-1"); + push_unicode(&mut payload, "S-1-15-2-1"); + push_unicode(&mut payload, r"\Device\HarddiskVolume3\app.exe"); + payload.extend(0u32.to_le_bytes()); + payload.extend(456u64.to_le_bytes()); + push_unicode(&mut payload, "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"); + push_unicode(&mut payload, "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"); + payload.extend(0u16.to_le_bytes()); + payload.extend(0u32.to_le_bytes()); + payload.push(0); + payload.push(6); + push_unicode(&mut payload, ""); + payload.extend(0u16.to_le_bytes()); + push_unicode(&mut payload, "203.0.113.10"); + payload.extend(443u16.to_le_bytes()); + payload.extend(0u32.to_le_bytes()); + + let mut record = event_record_for_payload(&payload); + record.EventHeader.ProviderId = crate::network_extractors::NETWORK_DECISION_PROVIDER; + record.EventHeader.EventDescriptor.Id = + crate::network_extractors::NETWORK_DECISION_EVENT_ID; + let info = unsafe { &*buffer.as_ptr() }; + let props = decode_properties(buffer.as_bytes(), info, &mut record, 8).expect("TDH decode"); + let parts = DecodedEventParts { + provider: record.EventHeader.ProviderId, + event_id: record.EventHeader.EventDescriptor.Id, + props, + }; + + let denial = + crate::network_extractors::extract_network_denial(&parts).expect("network denial"); + + assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); + assert!(matches!( + denial.details, + Some(learning_mode_core::DenialDetails::Network( + learning_mode_core::NetworkDenialDetails { + direction: learning_mode_core::NetworkDirection::Outbound, + protocol: Some(6), + remote_port: Some(443), + filter_id: 456, + .. + } + )) + )); + } + #[test] fn tdh_info_buffer_is_aligned_and_initialized() { let mut buffer = TdhInfoBuffer::new(std::mem::size_of::() + 7); From e62247010e1dc428c606e56153cf78e1154a4744 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Tue, 6 Oct 2026 13:49:27 -0700 Subject: [PATCH 04/13] fix: update WFP module paths after SDK consolidation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../core/learning_mode_windows/etl_decode.rs | 4 +-- .../core/learning_mode_windows/extractors.rs | 4 +-- .../core/learning_mode_windows/tdh_decode.rs | 26 +++++++++---------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 14e5bf5f1..e91469a62 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -2347,8 +2347,8 @@ mod tests { fn network_event(pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { event_with_provider( - super::network_extractors::NETWORK_DECISION_PROVIDER, - super::network_extractors::NETWORK_DECISION_EVENT_ID, + crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER, + crate::learning_mode_windows::network_extractors::NETWORK_DECISION_EVENT_ID, pid, filetime, kv, diff --git a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 655513c05..65d65d064 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -49,7 +49,7 @@ //! name; custom hashed capabilities fall back to the SID string). use crate::learning_mode_core::{ - AccessType, ResourceType, VerboseLoggingOutcomeReason, VerboseLoggingProvider, + AccessType, DenialDetails, ResourceType, VerboseLoggingOutcomeReason, VerboseLoggingProvider, }; use crate::mxc_common::hashing::sha256_hex; use windows::core::GUID; @@ -116,7 +116,7 @@ pub struct RawDenial { /// Kernel `FILETIME` of the event. pub filetime: u64, /// Optional resource-family-specific metadata. - pub details: Option, + pub details: Option, /// Originating ETW event ID (kept for diagnostics). pub event_id: u16, /// Symbolic category of the originating provider, for verbose logging diff --git a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs index a5a6c450d..ef783ee0e 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs @@ -1032,6 +1032,8 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::learning_mode_core::{DenialDetails, NetworkDenialDetails, NetworkDirection}; + use crate::learning_mode_windows::network_extractors; fn utf16_bytes(value: &str) -> Vec { value @@ -1150,9 +1152,8 @@ mod tests { payload.extend(0u32.to_le_bytes()); let mut record = event_record_for_payload(&payload); - record.EventHeader.ProviderId = crate::network_extractors::NETWORK_DECISION_PROVIDER; - record.EventHeader.EventDescriptor.Id = - crate::network_extractors::NETWORK_DECISION_EVENT_ID; + record.EventHeader.ProviderId = network_extractors::NETWORK_DECISION_PROVIDER; + record.EventHeader.EventDescriptor.Id = network_extractors::NETWORK_DECISION_EVENT_ID; let info = unsafe { &*buffer.as_ptr() }; let props = decode_properties(buffer.as_bytes(), info, &mut record, 8).expect("TDH decode"); let parts = DecodedEventParts { @@ -1161,21 +1162,18 @@ mod tests { props, }; - let denial = - crate::network_extractors::extract_network_denial(&parts).expect("network denial"); + let denial = network_extractors::extract_network_denial(&parts).expect("network denial"); assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); assert!(matches!( denial.details, - Some(learning_mode_core::DenialDetails::Network( - learning_mode_core::NetworkDenialDetails { - direction: learning_mode_core::NetworkDirection::Outbound, - protocol: Some(6), - remote_port: Some(443), - filter_id: 456, - .. - } - )) + Some(DenialDetails::Network(NetworkDenialDetails { + direction: NetworkDirection::Outbound, + protocol: Some(6), + remote_port: Some(443), + filter_id: 456, + .. + })) )); } From 17651a6cc8e158d1671e88185f90355e58a41221 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 7 Oct 2026 13:22:05 -0700 Subject: [PATCH 05/13] fix: address WFP decoder review findings Preserve the existing DeniedResource source contract, keep network metadata in redacted verbose signatures, fail closed on every schema gate, and avoid serialized-size work once verbose retention is saturated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/base_container_runner.rs | 1 - .../common/capture_output.rs | 1 - .../common/guarded_capture.rs | 2 - .../src/core/learning_mode_core/analyze.rs | 3 - .../src/core/learning_mode_core/emit.rs | 1 - .../src/core/learning_mode_core/mod.rs | 5 +- .../src/core/learning_mode_core/model.rs | 125 +----------------- .../learning_mode_core/verbose_logging.rs | 66 ++++++++- .../learning_mode_windows/capability_dacl.rs | 1 - .../core/learning_mode_windows/etl_decode.rs | 64 ++++++--- .../core/learning_mode_windows/extractors.rs | 19 ++- .../network_extractors.rs | 88 +++++------- .../core/learning_mode_windows/tdh_decode.rs | 11 -- src/mxc-sdk/src/core/plm/analysis.rs | 1 - src/mxc-sdk/src/core/plm/stop.rs | 1 - 15 files changed, 156 insertions(+), 233 deletions(-) diff --git a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs index ed147f7ea..4ad7ec097 100644 --- a/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs +++ b/src/mxc-sdk/src/backends/process_container/common/base_container_runner.rs @@ -2372,7 +2372,6 @@ mod tests { access_type: AccessType::Read, pid: 42, filetime: 99, - details: None, }])), }; diff --git a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs index 566141c15..909dab7c0 100644 --- a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs +++ b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs @@ -297,7 +297,6 @@ mod tests { access_type: AccessType::Read, pid: 42, filetime: 99, - details: None, }]); let metadata = diff --git a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs index ec8c0775d..37cc119cb 100644 --- a/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs +++ b/src/mxc-sdk/src/backends/process_container/common/guarded_capture.rs @@ -398,7 +398,6 @@ mod tests { access_type: AccessType::Read, pid: owner_pid, filetime: 1, - details: None, }]), })) } @@ -527,7 +526,6 @@ mod tests { access_type: AccessType::Read, pid: 4321, filetime: 7, - details: None, }]) } diff --git a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs index de7b0b9bc..47a9a04cc 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs @@ -259,7 +259,6 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, - details: None, }]; let analyzer: Box = Box::new(FakeAnalyzer(denials.clone())); let got = analyzer.analyze(Path::new("ignored.etl")).unwrap(); @@ -325,7 +324,6 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, - details: None, }], denied_resources_truncated: false, verbose_logging: VerboseLoggingSummary { @@ -392,7 +390,6 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 2, - details: None, }], denied_resources_truncated: false, verbose_logging: VerboseLoggingSummary { diff --git a/src/mxc-sdk/src/core/learning_mode_core/emit.rs b/src/mxc-sdk/src/core/learning_mode_core/emit.rs index c4f8eb5a5..fd246f0d0 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/emit.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/emit.rs @@ -154,7 +154,6 @@ mod tests { access_type: AccessType::Read, pid: 100, filetime: 200, - details: None, } } diff --git a/src/mxc-sdk/src/core/learning_mode_core/mod.rs b/src/mxc-sdk/src/core/learning_mode_core/mod.rs index f418f177b..b090001dc 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/mod.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/mod.rs @@ -41,10 +41,7 @@ pub mod verbose_logging; pub use analyze::{AnalysisResult, AnalyzeError, DenialAnalyzer, ProcessLifetime}; pub use emit::{write_document, DenialsDocument, DenialsOutputPointer}; -pub use model::{ - AccessType, DedupKey, DenialDetails, DeniedResource, NetworkDenialDetails, NetworkDenialReason, - NetworkDenialSource, NetworkDirection, ResourceType, -}; +pub use model::{AccessType, DedupKey, DeniedResource, ResourceType}; pub use paired_output::{ relocate_output_file, relocate_paired_output_files, write_paired_output_files, ExistingOutputPolicy, RelocationOutcome, diff --git a/src/mxc-sdk/src/core/learning_mode_core/model.rs b/src/mxc-sdk/src/core/learning_mode_core/model.rs index a4c2e157d..68a0e6fa3 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/model.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/model.rs @@ -74,82 +74,13 @@ pub enum AccessType { Unknown, } -/// Source component that produced an actionable network denial. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NetworkDenialSource { - /// ProcessContainer schema 0.8 network-policy enforcement. - ProcessContainerNetworkPolicy, -} - -/// Stable policy reason for an actionable network denial. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NetworkDenialReason { - /// A direct-egress attempt matched Tessera's default-deny baseline. - DirectDefaultDeny, -} - -/// Direction of a denied network operation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum NetworkDirection { - /// Inbound traffic. - Inbound, - /// Outbound traffic. - Outbound, - /// Forwarded traffic. - Forward, - /// The source supplied an unrecognized direction value. - Unknown, -} - -/// Additional structured data carried by an actionable network denial. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkDenialDetails { - /// Enforcement component that produced the denial. - pub source: NetworkDenialSource, - /// Stable policy reason assigned by the source adapter. - pub reason: NetworkDenialReason, - /// Traffic direction reported by WFP. - pub direction: NetworkDirection, - /// IP protocol number when the source supplied one. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub protocol: Option, - /// Local numeric IP address when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_address: Option, - /// Local host-order port when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_port: Option, - /// Remote numeric IP address. - pub remote_address: String, - /// Remote host-order port when available. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_port: Option, - /// Application identifier supplied by the WFP NetEvent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub application_id: Option, - /// Runtime WFP filter identifier used to correlate the event to its live filter. - #[serde(with = "decimal_u64")] - pub filter_id: u64, -} - -/// Resource-family-specific metadata attached to a denial. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum DenialDetails { - /// Structured WFP network-denial metadata. - Network(NetworkDenialDetails), -} - /// One denied `(resource, accessType)` observation surfaced to consumers. /// /// A `DeniedResource` describes a single resource the sandboxed workload /// was denied access to. Per-`(resource, accessType)` de-duplication happens /// in the decoder, so consumers can treat the emitted stream as already -/// unique. +/// unique. When repeated observations collapse to the same key, the retained +/// `pid` and `filetime` come from the first observation. /// /// # Examples /// @@ -162,7 +93,6 @@ pub enum DenialDetails { /// access_type: AccessType::Read, /// pid: 1234, /// filetime: 132_847_890_123_456_789, -/// details: None, /// }; /// let json = serde_json::to_string(&denial)?; /// assert!(json.contains("\"resourceType\":\"file\"")); @@ -204,11 +134,6 @@ pub struct DeniedResource { /// JavaScript consumers do not lose precision. #[serde(with = "decimal_u64")] pub filetime: u64, - - /// Optional resource-family-specific metadata. Existing non-network - /// records omit this field, preserving their JSON shape. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub details: Option, } /// De-duplication key for a denial: the `(resource, accessType)` pair. @@ -243,7 +168,6 @@ mod tests { access_type: AccessType::Read, pid: 1234, filetime: 132_847_890_123_456_789, - details: None, }; let json = serde_json::to_string(&r).unwrap(); assert!(json.contains("\"resource\":\"C:"), "got {json}"); @@ -263,7 +187,6 @@ mod tests { access_type: AccessType::Write, pid: 9999, filetime: 42, - details: None, }; let json = serde_json::to_string(&r).unwrap(); let parsed: DeniedResource = serde_json::from_str(&json).unwrap(); @@ -304,18 +227,6 @@ mod tests { } } - #[test] - fn network_direction_serialises_each_variant_to_lowercase() { - for (variant, expected) in [ - (NetworkDirection::Inbound, "\"inbound\""), - (NetworkDirection::Outbound, "\"outbound\""), - (NetworkDirection::Forward, "\"forward\""), - (NetworkDirection::Unknown, "\"unknown\""), - ] { - assert_eq!(serde_json::to_string(&variant).unwrap(), expected); - } - } - #[test] fn dedup_key_pairs_path_and_access_type() { let r = DeniedResource { @@ -324,39 +235,7 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 1, - details: None, }; assert_eq!(r.dedup_key(), (r"C:\a".to_string(), AccessType::Read)); } - - #[test] - fn network_details_are_additive_and_preserve_u64_filter_id() { - let r = DeniedResource { - resource: "tcp://203.0.113.10:443".to_string(), - resource_type: ResourceType::Network, - access_type: AccessType::Unknown, - pid: 0, - filetime: 42, - details: Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::ProcessContainerNetworkPolicy, - reason: NetworkDenialReason::DirectDefaultDeny, - direction: NetworkDirection::Outbound, - protocol: Some(6), - local_address: None, - local_port: None, - remote_address: "203.0.113.10".to_string(), - remote_port: Some(443), - application_id: Some(r"\device\harddiskvolume3\app.exe".to_string()), - filter_id: u64::MAX, - })), - }; - - let json = serde_json::to_value(&r).unwrap(); - assert_eq!(json["details"]["kind"], "network"); - assert_eq!(json["details"]["source"], "processContainerNetworkPolicy"); - assert_eq!(json["details"]["filterId"], u64::MAX.to_string()); - assert_eq!(json["details"]["reason"], "directDefaultDeny"); - assert!(json["details"].get("localAddress").is_none()); - assert_eq!(serde_json::from_value::(json).unwrap(), r); - } } diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index 44ab1b646..b17f185a5 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -193,14 +193,25 @@ impl VerboseLoggingSummary { return; } + let actionable = signature.reason.is_actionable(); + let can_evict_nonactionable = actionable + && self + .signatures + .iter() + .any(|group| !group.signature.reason.is_actionable()); + if (self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || *retained_bytes >= max_bytes) + && !can_evict_nonactionable + { + self.record_overflow(actionable); + return; + } + let serialized_len = Self::serialized_signature_len(&signature); while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || retained_bytes.saturating_add(serialized_len) > max_bytes { - if !signature.reason.is_actionable() - || !self.evict_one_nonactionable_group(Some(retained_bytes)) - { - self.record_overflow(signature.reason.is_actionable()); + if !actionable || !self.evict_one_nonactionable_group(Some(retained_bytes)) { + self.record_overflow(actionable); return; } } @@ -520,6 +531,53 @@ mod tests { .any(|group| { group.signature.reason == VerboseLoggingOutcomeReason::ComActivation })); } + #[test] + fn saturated_actionable_groups_still_count_retained_repeats() { + let mut summary = VerboseLoggingSummary::default(); + let mut retained_bytes = MAX_VERBOSE_LOGGING_SIGNATURE_BYTES; + for event_id in 0..MAX_VERBOSE_LOGGING_GROUPS as u16 { + summary.signatures.push(VerboseLoggingAggregate { + signature: VerboseLoggingSignature { + provider: VerboseLoggingProvider::KernelGeneral, + provider_guid: "kernel".to_string(), + event_id, + reason: VerboseLoggingOutcomeReason::Actionable, + pid: 1, + access_type: Some(crate::learning_mode_core::AccessType::Read), + resource_type: Some(crate::learning_mode_core::ResourceType::File), + properties: Vec::new(), + }, + count: 1, + }); + } + summary.total_occurrences = MAX_VERBOSE_LOGGING_GROUPS as u64; + + let retained = summary.signatures[0].signature.clone(); + summary.record_with_byte_budget( + retained, + &mut retained_bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + summary.record_with_byte_budget( + VerboseLoggingSignature { + provider: VerboseLoggingProvider::LearningModeNetworkDecision, + provider_guid: "network".to_string(), + event_id: u16::MAX, + reason: VerboseLoggingOutcomeReason::Actionable, + pid: 0, + access_type: Some(crate::learning_mode_core::AccessType::Unknown), + resource_type: Some(crate::learning_mode_core::ResourceType::Network), + properties: vec![("RemoteAddress".to_string(), "203.0.113.10".to_string())], + }, + &mut retained_bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + + assert_eq!(summary.signatures[0].count, 2); + assert_eq!(summary.overflow_occurrences, 1); + assert_eq!(summary.actionable_overflow_occurrences, 1); + } + #[test] fn byte_budget_leaves_guarded_analysis_protocol_headroom() { let mut summary = VerboseLoggingSummary::default(); diff --git a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs index 88280d590..085dd4791 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs @@ -269,7 +269,6 @@ pub(crate) fn extract_denials( object_name: name.to_string(), access_type: AccessType::Unknown, filetime, - details: None, event_id: parts.event_id, provider, verbose_logging_properties: diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index e91469a62..4913b81c0 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -305,7 +305,6 @@ impl<'visitor> Accumulator<'visitor> { access_type: raw.access_type, pid: raw.pid, filetime: raw.filetime, - details: raw.details, }); } @@ -1111,10 +1110,7 @@ fn normalized_filetime(timestamp: i64, acc: &mut Accumulator<'_>) -> Option #[cfg(test)] mod tests { use super::*; - use crate::learning_mode_core::{ - AccessType, DenialDetails, NetworkDenialDetails, NetworkDenialReason, NetworkDenialSource, - NetworkDirection, ResourceType, - }; + use crate::learning_mode_core::{AccessType, ResourceType}; const SCOPED_PID: u32 = 42; const SCOPED_START_FILETIME: u64 = 100; @@ -2016,7 +2012,6 @@ mod tests { object_name: path.to_string(), access_type: access, filetime: 1, - details: None, event_id: 4907, provider: crate::learning_mode_core::VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, @@ -2051,6 +2046,26 @@ mod tests { assert_eq!(out[1].resource, r"C:\a"); } + #[test] + fn dedup_retains_first_observation_metadata() { + let mut first = raw( + "tcp://203.0.113.10:443", + AccessType::Unknown, + ResourceType::Network, + ); + first.pid = 10; + first.filetime = 100; + let mut later = first.clone(); + later.pid = 20; + later.filetime = 200; + + let out = dedup_to_resources([first, later]).denials; + + assert_eq!(out.len(), 1); + assert_eq!(out[0].pid, 10); + assert_eq!(out[0].filetime, 100); + } + #[test] fn file_denials_require_a_canonical_user_visible_path() { let denials = vec![ @@ -2152,7 +2167,6 @@ mod tests { access_type: AccessType::Read, pid: 1, filetime: 1, - details: None, }) .collect(); accumulator.seen = (0..MAX_UNIQUE_DENIALS) @@ -2414,21 +2428,27 @@ mod tests { assert_eq!(analysis.denials[0].pid, 0); assert_eq!(analysis.denials[0].filetime, 500); assert_eq!(analysis.denials[1].resource, "tcp://203.0.113.10:443"); - assert_eq!( - analysis.denials[1].details, - Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::ProcessContainerNetworkPolicy, - reason: NetworkDenialReason::DirectDefaultDeny, - direction: NetworkDirection::Outbound, - protocol: Some(6), - local_address: None, - local_port: None, - remote_address: "203.0.113.10".to_string(), - remote_port: Some(443), - application_id: Some(r"\Device\HarddiskVolume3\app.exe".to_string()), - filter_id: 9001, - })) - ); + let network_signature = analysis + .verbose_logging + .signatures + .iter() + .find(|aggregate| { + aggregate.signature.provider == VerboseLoggingProvider::LearningModeNetworkDecision + && aggregate.signature.reason == VerboseLoggingOutcomeReason::Actionable + && aggregate.signature.resource_type == Some(ResourceType::Network) + }) + .expect("actionable network signature"); + assert!(network_signature + .signature + .properties + .iter() + .any(|(name, value)| name == "ApplicationId" + && value == crate::learning_mode_windows::extractors::REDACTED_PATH)); + assert!(network_signature + .signature + .properties + .iter() + .all(|(_, value)| value != r"\Device\HarddiskVolume3\app.exe")); assert!(analysis.verbose_logging.signatures.iter().any(|aggregate| { aggregate.signature.provider == VerboseLoggingProvider::LearningModeNetworkDecision && aggregate.signature.reason diff --git a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 65d65d064..5497cf174 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -49,7 +49,7 @@ //! name; custom hashed capabilities fall back to the SID string). use crate::learning_mode_core::{ - AccessType, DenialDetails, ResourceType, VerboseLoggingOutcomeReason, VerboseLoggingProvider, + AccessType, ResourceType, VerboseLoggingOutcomeReason, VerboseLoggingProvider, }; use crate::mxc_common::hashing::sha256_hex; use windows::core::GUID; @@ -115,8 +115,6 @@ pub struct RawDenial { pub access_type: AccessType, /// Kernel `FILETIME` of the event. pub filetime: u64, - /// Optional resource-family-specific metadata. - pub details: Option, /// Originating ETW event ID (kept for diagnostics). pub event_id: u16, /// Symbolic category of the originating provider, for verbose logging @@ -792,7 +790,6 @@ pub fn build_denial_from_access_check( object_name, access_type, filetime, - details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), @@ -883,7 +880,6 @@ pub fn build_denial_from_learning_mode( object_name, access_type: AccessType::Unknown, filetime, - details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), @@ -951,7 +947,6 @@ pub fn build_denial_from_capability( object_name, access_type: AccessType::Unknown, filetime, - details: None, event_id: parts.event_id, provider, verbose_logging_properties: sanitize_properties(&parts.props), @@ -2265,6 +2260,18 @@ mod tests { ); } + #[test] + fn sanitize_properties_redacts_network_application_id() { + let application_id = r"\Device\HarddiskVolume3\app.exe"; + let out = sanitize_properties(&[("ApplicationId".to_string(), application_id.to_string())]); + + assert_eq!( + out, + vec![("ApplicationId".to_string(), REDACTED_PATH.to_string())] + ); + assert!(out.iter().all(|(_, value)| value != application_id)); + } + #[test] fn sanitize_properties_never_carries_a_decoder_error_message() { // Decode-failure signatures are built by the caller with an empty diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 752b768ff..2812e53aa 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -4,8 +4,7 @@ //! Extractors for the OS-managed Learning Mode WFP decision event. use crate::learning_mode_core::{ - AccessType, DenialDetails, NetworkDenialDetails, NetworkDenialReason, NetworkDenialSource, - NetworkDirection, ResourceType, VerboseLoggingOutcomeReason as VerboseLoggingExclusionReason, + AccessType, ResourceType, VerboseLoggingOutcomeReason as VerboseLoggingExclusionReason, VerboseLoggingProvider, }; use std::net::IpAddr; @@ -124,7 +123,6 @@ fn extract_app_isolation( object_name: capability.to_string(), access_type: AccessType::Unknown, filetime, - details: None, event_id: parts.event_id, provider: VerboseLoggingProvider::LearningModeNetworkDecision, verbose_logging_properties: sanitize_properties(&parts.props), @@ -136,7 +134,7 @@ fn extract_tessera( reason: u16, field_flags: u32, filetime: u64, - filter_id: u64, + _filter_id: u64, ) -> Result { if !property_eq(parts, "ProviderGuid", TESSERA_PROVIDER) || !property_eq(parts, "SublayerGuid", TESSERA_SUBLAYER) @@ -163,13 +161,13 @@ fn extract_tessera( let remote_address = required_ip_string(parts, "RemoteAddress")?; let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; - let local_address = + let _local_address = optional_ip_string(parts, field_flags, FIELD_LOCAL_ADDRESS, "LocalAddress")?; - let local_port = optional_u16(parts, field_flags, FIELD_LOCAL_PORT, "LocalPort")?; + let _local_port = optional_u16(parts, field_flags, FIELD_LOCAL_PORT, "LocalPort")?; let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; - let application_id = + let _application_id = optional_string(parts, field_flags, FIELD_APPLICATION_ID, "ApplicationId")?; - let direction = required_u32(parts, "Direction").map(network_direction)?; + let _direction = required_u32(parts, "Direction")?; let resource = format_network_resource(protocol, &remote_address, remote_port); Ok(RawDenial { @@ -178,32 +176,12 @@ fn extract_tessera( object_name: resource, access_type: AccessType::Unknown, filetime, - details: Some(DenialDetails::Network(NetworkDenialDetails { - source: NetworkDenialSource::ProcessContainerNetworkPolicy, - reason: NetworkDenialReason::DirectDefaultDeny, - direction, - protocol, - local_address, - local_port, - remote_address, - remote_port, - application_id, - filter_id, - })), event_id: parts.event_id, provider: VerboseLoggingProvider::LearningModeNetworkDecision, verbose_logging_properties: sanitize_properties(&parts.props), }) } -fn network_direction(value: u32) -> NetworkDirection { - match value { - 0 => NetworkDirection::Outbound, - 1 => NetworkDirection::Inbound, - _ => NetworkDirection::Unknown, - } -} - fn format_network_resource(protocol: Option, address: &str, port: Option) -> String { let scheme = match protocol { Some(6) => "tcp".to_string(), @@ -464,7 +442,7 @@ mod tests { } #[test] - fn tessera_direct_default_deny_preserves_network_details() { + fn tessera_direct_default_deny_builds_stable_network_resource() { let flags = FIELD_APPLICATION_ID | FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; let mut parts = event( @@ -482,25 +460,11 @@ mod tests { let denial = extract_network_denial(&parts).unwrap(); assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); assert_eq!(denial.resource_type, ResourceType::Network); - assert!(matches!( - denial.details, - Some(DenialDetails::Network(NetworkDenialDetails { - direction: NetworkDirection::Outbound, - protocol: Some(6), - remote_port: Some(443), - filter_id: 456, - .. - })) - )); } #[test] - fn tessera_direction_values_map_to_stable_variants() { - for (direction, expected) in [ - (0, NetworkDirection::Outbound), - (1, NetworkDirection::Inbound), - (u32::MAX, NetworkDirection::Unknown), - ] { + fn tessera_accepts_provider_direction_values_without_exposing_a_public_enum() { + for direction in [0, 1, u32::MAX] { let mut parts = event( SOURCE_TESSERA, REASON_TESSERA_DIRECT_DEFAULT_DENY, @@ -512,13 +476,7 @@ mod tests { replace(&mut parts, "FieldFlags", FIELD_REMOTE_ADDRESS.to_string()); let denial = extract_network_denial(&parts).unwrap(); - assert!(matches!( - denial.details, - Some(DenialDetails::Network(NetworkDenialDetails { - direction: actual, - .. - })) if actual == expected - )); + assert_eq!(denial.object_name, "ip://203.0.113.10"); } } @@ -623,6 +581,32 @@ mod tests { ); } + #[test] + fn schema_and_decision_guards_fail_closed_independently() { + for (name, value) in [ + ("SchemaVersion", "2"), + ("Mode", "0"), + ("NormalDecision", "0"), + ("EffectiveDecision", "0"), + ("OriginalTimestamp", "0"), + ("FilterId", "0"), + ] { + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[("RemoteAddress", "203.0.113.10")], + ); + replace(&mut parts, "FieldFlags", FIELD_REMOTE_ADDRESS.to_string()); + replace(&mut parts, name, value); + + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnsupportedEventSchema, + "guard {name}={value} must fail closed" + ); + } + } + #[test] fn shipped_v1_payload_uses_the_original_24_fields() { let parts = event( diff --git a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs index ef783ee0e..8b92a876b 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs @@ -1032,7 +1032,6 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::learning_mode_core::{DenialDetails, NetworkDenialDetails, NetworkDirection}; use crate::learning_mode_windows::network_extractors; fn utf16_bytes(value: &str) -> Vec { @@ -1165,16 +1164,6 @@ mod tests { let denial = network_extractors::extract_network_denial(&parts).expect("network denial"); assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); - assert!(matches!( - denial.details, - Some(DenialDetails::Network(NetworkDenialDetails { - direction: NetworkDirection::Outbound, - protocol: Some(6), - remote_port: Some(443), - filter_id: 456, - .. - })) - )); } #[test] diff --git a/src/mxc-sdk/src/core/plm/analysis.rs b/src/mxc-sdk/src/core/plm/analysis.rs index 7ff5769c9..59054820e 100644 --- a/src/mxc-sdk/src/core/plm/analysis.rs +++ b/src/mxc-sdk/src/core/plm/analysis.rs @@ -194,7 +194,6 @@ mod tests { access_type, pid: 42, filetime: 1, - details: None, } } diff --git a/src/mxc-sdk/src/core/plm/stop.rs b/src/mxc-sdk/src/core/plm/stop.rs index a6e61d609..42c4cad5f 100644 --- a/src/mxc-sdk/src/core/plm/stop.rs +++ b/src/mxc-sdk/src/core/plm/stop.rs @@ -510,7 +510,6 @@ mod tests { access_type: AccessType::Unknown, pid: 42, filetime: 1, - details: None, }], DenialSummary::new(7, 1, false), ); From 2070980fdf72c9e4d09a86c98fa688229ccf7f06 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Wed, 7 Oct 2026 13:35:25 -0700 Subject: [PATCH 06/13] fix: version WFP verbose diagnostics Make the public provider and outcome enums extensible, bump the closed verbose JSON vocabulary to version 3, and cache actionable-only saturation so high-cardinality overflow avoids repeated scans and serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../learning_mode_core/verbose_logging.rs | 73 ++++++++++--------- .../core/learning_mode_windows/etl_decode.rs | 20 +++++ 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index b17f185a5..92f4d0807 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -20,6 +20,7 @@ pub const MAX_VERBOSE_LOGGING_SIGNATURE_BYTES: usize = 16 * 1024 * 1024; /// Stable category for a known Learning Mode ETW provider. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum VerboseLoggingProvider { /// Microsoft-Windows-Kernel-General. KernelGeneral, @@ -32,6 +33,7 @@ pub enum VerboseLoggingProvider { /// Closed reason describing how a decoder outcome was handled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum VerboseLoggingOutcomeReason { /// The event produced a valid actionable denial. Actionable, @@ -193,25 +195,14 @@ impl VerboseLoggingSummary { return; } - let actionable = signature.reason.is_actionable(); - let can_evict_nonactionable = actionable - && self - .signatures - .iter() - .any(|group| !group.signature.reason.is_actionable()); - if (self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || *retained_bytes >= max_bytes) - && !can_evict_nonactionable - { - self.record_overflow(actionable); - return; - } - let serialized_len = Self::serialized_signature_len(&signature); while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || retained_bytes.saturating_add(serialized_len) > max_bytes { - if !actionable || !self.evict_one_nonactionable_group(Some(retained_bytes)) { - self.record_overflow(actionable); + if !signature.reason.is_actionable() + || !self.evict_one_nonactionable_group(Some(retained_bytes)) + { + self.record_overflow(signature.reason.is_actionable()); return; } } @@ -231,6 +222,25 @@ impl VerboseLoggingSummary { *retained_bytes = retained_bytes.saturating_add(serialized_len); } + /// Records an actionable outcome after the caller has established that + /// retention is saturated and every retained group is actionable. + pub(crate) fn record_actionable_after_saturation( + &mut self, + signature: VerboseLoggingSignature, + ) { + debug_assert!(signature.reason.is_actionable()); + match self + .signatures + .binary_search_by(|group| group.signature.cmp(&signature)) + { + Ok(index) => { + self.total_occurrences = self.total_occurrences.saturating_add(1); + self.signatures[index].count = self.signatures[index].count.saturating_add(1); + } + Err(_) => self.record_overflow(true), + } + } + /// Counts an outcome whose new signature could not be retained at a bound. pub fn record_overflow(&mut self, actionable: bool) { self.total_occurrences = self.total_occurrences.saturating_add(1); @@ -534,7 +544,7 @@ mod tests { #[test] fn saturated_actionable_groups_still_count_retained_repeats() { let mut summary = VerboseLoggingSummary::default(); - let mut retained_bytes = MAX_VERBOSE_LOGGING_SIGNATURE_BYTES; + let retained_bytes = MAX_VERBOSE_LOGGING_SIGNATURE_BYTES; for event_id in 0..MAX_VERBOSE_LOGGING_GROUPS as u16 { summary.signatures.push(VerboseLoggingAggregate { signature: VerboseLoggingSignature { @@ -553,29 +563,22 @@ mod tests { summary.total_occurrences = MAX_VERBOSE_LOGGING_GROUPS as u64; let retained = summary.signatures[0].signature.clone(); - summary.record_with_byte_budget( - retained, - &mut retained_bytes, - MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, - ); - summary.record_with_byte_budget( - VerboseLoggingSignature { - provider: VerboseLoggingProvider::LearningModeNetworkDecision, - provider_guid: "network".to_string(), - event_id: u16::MAX, - reason: VerboseLoggingOutcomeReason::Actionable, - pid: 0, - access_type: Some(crate::learning_mode_core::AccessType::Unknown), - resource_type: Some(crate::learning_mode_core::ResourceType::Network), - properties: vec![("RemoteAddress".to_string(), "203.0.113.10".to_string())], - }, - &mut retained_bytes, - MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, - ); + summary.record_actionable_after_saturation(retained); + summary.record_actionable_after_saturation(VerboseLoggingSignature { + provider: VerboseLoggingProvider::LearningModeNetworkDecision, + provider_guid: "network".to_string(), + event_id: u16::MAX, + reason: VerboseLoggingOutcomeReason::Actionable, + pid: 0, + access_type: Some(crate::learning_mode_core::AccessType::Unknown), + resource_type: Some(crate::learning_mode_core::ResourceType::Network), + properties: vec![("RemoteAddress".to_string(), "203.0.113.10".to_string())], + }); assert_eq!(summary.signatures[0].count, 2); assert_eq!(summary.overflow_occurrences, 1); assert_eq!(summary.actionable_overflow_occurrences, 1); + assert_eq!(retained_bytes, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES); } #[test] diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 4913b81c0..04b9865ab 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -149,6 +149,7 @@ struct Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary, verbose_logging_signature_bytes: usize, skip_relog_header: bool, + verbose_logging_actionable_only_saturated: bool, } impl<'visitor> Accumulator<'visitor> { @@ -173,6 +174,7 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), verbose_logging_signature_bytes: 0, skip_relog_header: false, + verbose_logging_actionable_only_saturated: false, } } @@ -204,6 +206,7 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), verbose_logging_signature_bytes: 0, skip_relog_header: false, + verbose_logging_actionable_only_saturated: false, } } @@ -228,6 +231,7 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), verbose_logging_signature_bytes: 0, skip_relog_header: false, + verbose_logging_actionable_only_saturated: false, } } @@ -391,11 +395,27 @@ impl<'visitor> Accumulator<'visitor> { resource_type, properties, }; + if self.verbose_logging_actionable_only_saturated && reason.is_actionable() { + self.verbose_logging + .record_actionable_after_saturation(signature); + return; + } + let overflow_before = self.verbose_logging.overflow_occurrences; self.verbose_logging.record_with_byte_budget( signature, &mut self.verbose_logging_signature_bytes, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, ); + if reason.is_actionable() + && self.verbose_logging.overflow_occurrences > overflow_before + && self + .verbose_logging + .signatures + .iter() + .all(|group| group.signature.reason.is_actionable()) + { + self.verbose_logging_actionable_only_saturated = true; + } } fn event_in_scope(&self, pid: u32, filetime: u64) -> bool { From 4aa5bb7138639a2dbd5c62725e53a7544948d172 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:06:27 -0700 Subject: [PATCH 07/13] fix: preserve 1.x WFP diagnostic compatibility Keep the published verbose enums exhaustive, route complete WFP diagnostics through an internal version-5 model, and retain reason-specific configuration guidance without changing DeniedResource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../learning_mode_core/capture_diagnostics.rs | 581 ++++++++++++++++++ .../src/core/learning_mode_core/mod.rs | 1 + .../src/core/learning_mode_core/model.rs | 17 +- .../learning_mode_core/verbose_logging.rs | 26 +- .../learning_mode_windows/capability_dacl.rs | 2 +- .../core/learning_mode_windows/etl_decode.rs | 433 ++++++++++--- .../core/learning_mode_windows/extractors.rs | 19 +- .../network_extractors.rs | 423 +++++++++++-- .../core/learning_mode_windows/tdh_decode.rs | 4 +- .../src/core/mxc_engine/verbose_telemetry.rs | 100 +-- .../tests/learning_mode_public_api_compat.rs | 43 ++ 11 files changed, 1415 insertions(+), 234 deletions(-) create mode 100644 src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs create mode 100644 src/mxc-sdk/tests/learning_mode_public_api_compat.rs diff --git a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs new file mode 100644 index 000000000..2849ee7be --- /dev/null +++ b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Crate-internal diagnostics used by MXC capture paths. + +use std::io::{self, Write}; + +use serde::{Deserialize, Serialize}; + +use super::{ + AccessType, AnalysisResult, ResourceType, VerboseLoggingAggregate, VerboseLoggingOutcomeReason, + VerboseLoggingProvider, VerboseLoggingSummary, MAX_VERBOSE_LOGGING_GROUPS, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, +}; + +/// Verbose document version owned by the WFP diagnostic extension. +pub(crate) const CAPTURE_VERBOSE_LOGGING_VERSION: u32 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum CaptureVerboseLoggingProvider { + Other, + KernelGeneral, + PrivacyAuditingPermissiveLearningMode, + LearningModeNetworkDecision, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum CaptureVerboseLoggingOutcomeReason { + Actionable, + UnsupportedEventSchema, + SchemaUnavailable, + EventPayloadMalformed, + DecoderLimitReached, + UnsupportedPropertyEncoding, + MissingObjectType, + MissingObjectName, + UnsupportedObjectType, + UnusableResourcePath, + UnresolvedCapability, + ComActivation, + ComInterfaceCall, + NotActionable, + IntentionalNetworkPolicyDeny, + ProxyContainment, + UnknownNetworkReason, + IncompleteNetworkEndpoint, +} + +impl CaptureVerboseLoggingOutcomeReason { + pub(crate) fn is_actionable(self) -> bool { + self == Self::Actionable + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum NetworkDecisionReason { + AppIsolationMissingCapability, + DirectDefaultDeny, + AuthoredExplicitDeny, + AllowExclusion, + ProxyContainment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ConfigurationRecommendation { + AddCapability, + AddEgressAllow, + ReviewEgressDeny, + ReviewAllowExclusion, + UseConfiguredProxy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NetworkEndpoint { + pub(crate) protocol: String, + pub(crate) remote_address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remote_port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureVerboseLoggingSignature { + pub(crate) provider: CaptureVerboseLoggingProvider, + pub(crate) provider_guid: String, + pub(crate) event_id: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) event_name: Option, + pub(crate) reason: CaptureVerboseLoggingOutcomeReason, + pub(crate) pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) access_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) resource_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) network_decision_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) configuration_recommendation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) network_endpoint: Option, + pub(crate) properties: Vec<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureVerboseLoggingAggregate { + pub(crate) signature: CaptureVerboseLoggingSignature, + pub(crate) count: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CaptureVerboseLoggingSummary { + pub(crate) signatures: Vec, + pub(crate) total_occurrences: u64, + pub(crate) overflow_occurrences: u64, + pub(crate) actionable_overflow_occurrences: u64, + pub(crate) aggregate_groups_truncated: bool, + pub(crate) processed_events_truncated: bool, + pub(crate) actionable_limit_reached: bool, +} + +impl CaptureVerboseLoggingSummary { + pub(crate) fn record_with_byte_budget( + &mut self, + signature: CaptureVerboseLoggingSignature, + retained_bytes: &mut usize, + max_bytes: usize, + ) { + if let Ok(index) = self + .signatures + .binary_search_by(|group| group.signature.cmp(&signature)) + { + self.total_occurrences = self.total_occurrences.saturating_add(1); + self.signatures[index].count = self.signatures[index].count.saturating_add(1); + return; + } + + let serialized_len = serialized_signature_len(&signature); + while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS + || retained_bytes.saturating_add(serialized_len) > max_bytes + { + if !signature.reason.is_actionable() + || !self.evict_one_nonactionable_group(retained_bytes) + { + self.record_overflow(signature.reason.is_actionable(), 1); + return; + } + } + + let index = self + .signatures + .binary_search_by(|group| group.signature.cmp(&signature)) + .unwrap_err(); + self.signatures.insert( + index, + CaptureVerboseLoggingAggregate { + signature, + count: 1, + }, + ); + self.total_occurrences = self.total_occurrences.saturating_add(1); + *retained_bytes = retained_bytes.saturating_add(serialized_len); + } + + pub(crate) fn record_actionable_after_saturation( + &mut self, + signature: CaptureVerboseLoggingSignature, + ) { + debug_assert!(signature.reason.is_actionable()); + match self + .signatures + .binary_search_by(|group| group.signature.cmp(&signature)) + { + Ok(index) => { + self.total_occurrences = self.total_occurrences.saturating_add(1); + self.signatures[index].count = self.signatures[index].count.saturating_add(1); + } + Err(_) => self.record_overflow(true, 1), + } + } + + pub(crate) fn actionable_occurrences(&self) -> u64 { + self.signatures + .iter() + .filter(|aggregate| aggregate.signature.reason.is_actionable()) + .fold(self.actionable_overflow_occurrences, |total, aggregate| { + total.saturating_add(aggregate.count) + }) + } + + fn record_overflow(&mut self, actionable: bool, count: u64) { + self.total_occurrences = self.total_occurrences.saturating_add(count); + self.overflow_occurrences = self.overflow_occurrences.saturating_add(count); + if actionable { + self.actionable_overflow_occurrences = + self.actionable_overflow_occurrences.saturating_add(count); + } + self.aggregate_groups_truncated = true; + } + + fn evict_one_nonactionable_group(&mut self, retained_bytes: &mut usize) -> bool { + let Some(index) = self + .signatures + .iter() + .rposition(|group| !group.signature.reason.is_actionable()) + else { + return false; + }; + let aggregate = self.signatures.remove(index); + *retained_bytes = + retained_bytes.saturating_sub(serialized_signature_len(&aggregate.signature)); + self.overflow_occurrences = self.overflow_occurrences.saturating_add(aggregate.count); + self.aggregate_groups_truncated = true; + true + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureVerboseLoggingDocument { + pub(crate) version: u32, + pub(crate) signatures: Vec, + pub(crate) summary: CaptureVerboseLoggingDocumentSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureVerboseLoggingDocumentSummary { + pub(crate) total_occurrences: u64, + pub(crate) overflow_occurrences: u64, + pub(crate) actionable_overflow_occurrences: u64, + pub(crate) aggregate_groups_truncated: bool, + pub(crate) processed_events_truncated: bool, + pub(crate) actionable_limit_reached: bool, +} + +impl CaptureVerboseLoggingDocument { + #[allow(dead_code)] // Consumed by the stacked product-output integration. + pub(crate) fn new( + legacy: &VerboseLoggingSummary, + network: &CaptureVerboseLoggingSummary, + ) -> Self { + let mut candidates = legacy + .signatures + .iter() + .map(convert_legacy_aggregate) + .chain(network.signatures.iter().cloned()) + .collect::>(); + candidates.sort_by_key(|aggregate| !aggregate.signature.reason.is_actionable()); + + let mut retained = Vec::new(); + let mut retained_bytes = 0usize; + let mut overflow_occurrences = legacy + .overflow_occurrences + .saturating_add(network.overflow_occurrences); + let mut actionable_overflow_occurrences = legacy + .actionable_overflow_occurrences + .saturating_add(network.actionable_overflow_occurrences); + let mut aggregate_groups_truncated = + legacy.aggregate_groups_truncated || network.aggregate_groups_truncated; + + for aggregate in candidates { + let serialized_len = serialized_signature_len(&aggregate.signature); + if retained.len() < MAX_VERBOSE_LOGGING_GROUPS + && retained_bytes.saturating_add(serialized_len) + <= MAX_VERBOSE_LOGGING_SIGNATURE_BYTES + { + retained_bytes = retained_bytes.saturating_add(serialized_len); + retained.push(aggregate); + } else { + overflow_occurrences = overflow_occurrences.saturating_add(aggregate.count); + if aggregate.signature.reason.is_actionable() { + actionable_overflow_occurrences = + actionable_overflow_occurrences.saturating_add(aggregate.count); + } + aggregate_groups_truncated = true; + } + } + retained.sort_by(|left, right| left.signature.cmp(&right.signature)); + + Self { + version: CAPTURE_VERBOSE_LOGGING_VERSION, + signatures: retained, + summary: CaptureVerboseLoggingDocumentSummary { + total_occurrences: legacy + .total_occurrences + .saturating_add(network.total_occurrences), + overflow_occurrences, + actionable_overflow_occurrences, + aggregate_groups_truncated, + processed_events_truncated: legacy.processed_events_truncated + || network.processed_events_truncated, + actionable_limit_reached: legacy.actionable_limit_reached + || network.actionable_limit_reached, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CaptureAnalysis { + pub(crate) denials: Vec, + pub(crate) denied_resources_truncated: bool, + pub(crate) verbose_logging: VerboseLoggingSummary, + pub(crate) network_verbose_logging: CaptureVerboseLoggingSummary, +} + +impl CaptureAnalysis { + pub(crate) fn into_legacy(mut self) -> AnalysisResult { + let omitted = self.network_verbose_logging.total_occurrences; + if omitted > 0 { + self.verbose_logging.total_occurrences = self + .verbose_logging + .total_occurrences + .saturating_add(omitted); + self.verbose_logging.overflow_occurrences = self + .verbose_logging + .overflow_occurrences + .saturating_add(omitted); + self.verbose_logging.actionable_overflow_occurrences = self + .verbose_logging + .actionable_overflow_occurrences + .saturating_add(self.network_verbose_logging.actionable_occurrences()); + self.verbose_logging.aggregate_groups_truncated = true; + self.verbose_logging.processed_events_truncated |= + self.network_verbose_logging.processed_events_truncated; + self.verbose_logging.actionable_limit_reached |= + self.network_verbose_logging.actionable_limit_reached; + } + AnalysisResult { + denials: self.denials, + denied_resources_truncated: self.denied_resources_truncated, + verbose_logging: self.verbose_logging, + } + } + + #[allow(dead_code)] // Consumed by the stacked product-output integration. + pub(crate) fn verbose_document(&self) -> CaptureVerboseLoggingDocument { + CaptureVerboseLoggingDocument::new(&self.verbose_logging, &self.network_verbose_logging) + } +} + +#[allow(dead_code)] // Consumed by the stacked product-output integration. +pub(crate) fn write_capture_verbose_logging_document( + writer: &mut W, + document: &CaptureVerboseLoggingDocument, +) -> io::Result<()> { + serde_json::to_writer_pretty(&mut *writer, document).map_err(io::Error::other)?; + writer.write_all(b"\n")?; + writer.flush() +} + +pub(crate) fn parse_supported_verbose_document( + bytes: &[u8], +) -> Result { + let document: CaptureVerboseLoggingDocument = serde_json::from_slice(bytes)?; + if matches!(document.version, 3 | 4 | CAPTURE_VERBOSE_LOGGING_VERSION) { + Ok(document) + } else { + Err(::custom( + "unsupported verbose logging document version", + )) + } +} + +#[allow(dead_code)] // Used when the product-output integration builds v5. +fn convert_legacy_aggregate(aggregate: &VerboseLoggingAggregate) -> CaptureVerboseLoggingAggregate { + CaptureVerboseLoggingAggregate { + signature: CaptureVerboseLoggingSignature { + provider: match aggregate.signature.provider { + VerboseLoggingProvider::KernelGeneral => { + CaptureVerboseLoggingProvider::KernelGeneral + } + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + } + }, + provider_guid: aggregate.signature.provider_guid.clone(), + event_id: aggregate.signature.event_id, + event_name: None, + reason: convert_legacy_reason(aggregate.signature.reason), + pid: aggregate.signature.pid, + access_type: aggregate.signature.access_type, + resource_type: aggregate.signature.resource_type, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + properties: aggregate.signature.properties.clone(), + }, + count: aggregate.count, + } +} + +#[allow(dead_code)] // Used when the product-output integration builds v5. +fn convert_legacy_reason( + reason: VerboseLoggingOutcomeReason, +) -> CaptureVerboseLoggingOutcomeReason { + match reason { + VerboseLoggingOutcomeReason::Actionable => CaptureVerboseLoggingOutcomeReason::Actionable, + VerboseLoggingOutcomeReason::UnsupportedEventSchema => { + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema + } + VerboseLoggingOutcomeReason::EventPayloadMalformed => { + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed + } + VerboseLoggingOutcomeReason::DecoderLimitReached => { + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached + } + VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding => { + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + VerboseLoggingOutcomeReason::MissingObjectType => { + CaptureVerboseLoggingOutcomeReason::MissingObjectType + } + VerboseLoggingOutcomeReason::MissingObjectName => { + CaptureVerboseLoggingOutcomeReason::MissingObjectName + } + VerboseLoggingOutcomeReason::UnsupportedObjectType => { + CaptureVerboseLoggingOutcomeReason::UnsupportedObjectType + } + VerboseLoggingOutcomeReason::UnusableResourcePath => { + CaptureVerboseLoggingOutcomeReason::UnusableResourcePath + } + VerboseLoggingOutcomeReason::UnresolvedCapability => { + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability + } + VerboseLoggingOutcomeReason::ComActivation => { + CaptureVerboseLoggingOutcomeReason::ComActivation + } + VerboseLoggingOutcomeReason::ComInterfaceCall => { + CaptureVerboseLoggingOutcomeReason::ComInterfaceCall + } + VerboseLoggingOutcomeReason::NotActionable => { + CaptureVerboseLoggingOutcomeReason::NotActionable + } + } +} + +fn serialized_signature_len(signature: &CaptureVerboseLoggingSignature) -> usize { + serde_json::to_vec(&CaptureVerboseLoggingAggregate { + signature: signature.clone(), + count: 1, + }) + .map_or(usize::MAX, |bytes| bytes.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::learning_mode_core::{ + DeniedResource, VerboseLoggingDocument, VerboseLoggingSignature, + }; + + fn network_signature( + reason: CaptureVerboseLoggingOutcomeReason, + ) -> CaptureVerboseLoggingSignature { + CaptureVerboseLoggingSignature { + provider: CaptureVerboseLoggingProvider::LearningModeNetworkDecision, + provider_guid: "{71237669-21C3-4101-BD2F-FF38945D725A}".to_string(), + event_id: 1, + event_name: Some("NetworkDecisionV1".to_string()), + reason, + pid: 0, + access_type: Some(AccessType::Unknown), + resource_type: Some(ResourceType::Network), + network_decision_reason: Some(NetworkDecisionReason::DirectDefaultDeny), + configuration_recommendation: Some(ConfigurationRecommendation::AddEgressAllow), + network_endpoint: Some(NetworkEndpoint { + protocol: "tcp".to_string(), + remote_address: "203.0.113.10".to_string(), + remote_port: Some(443), + }), + properties: Vec::new(), + } + } + + #[test] + fn legacy_projection_keeps_denial_and_counts_network_diagnostic_as_overflow() { + let mut network = CaptureVerboseLoggingSummary::default(); + let mut bytes = 0; + network.record_with_byte_budget( + network_signature(CaptureVerboseLoggingOutcomeReason::Actionable), + &mut bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + let analysis = CaptureAnalysis { + denials: vec![DeniedResource { + resource: "tcp://203.0.113.10:443".to_string(), + resource_type: ResourceType::Network, + access_type: AccessType::Unknown, + pid: 0, + filetime: 123, + }], + denied_resources_truncated: false, + verbose_logging: VerboseLoggingSummary::default(), + network_verbose_logging: network, + }; + + let legacy = analysis.into_legacy(); + assert_eq!(legacy.denials.len(), 1); + assert!(legacy.verbose_logging.signatures.is_empty()); + assert_eq!(legacy.verbose_logging.total_occurrences, 1); + assert_eq!(legacy.verbose_logging.overflow_occurrences, 1); + assert_eq!(legacy.verbose_logging.actionable_overflow_occurrences, 1); + assert!(legacy.verbose_logging.aggregate_groups_truncated); + } + + #[test] + fn version_five_document_merges_legacy_and_network_groups() { + let legacy = VerboseLoggingSummary { + signatures: vec![VerboseLoggingAggregate { + signature: VerboseLoggingSignature { + provider: VerboseLoggingProvider::KernelGeneral, + provider_guid: "kernel".to_string(), + event_id: 14, + reason: VerboseLoggingOutcomeReason::Actionable, + pid: 42, + access_type: Some(AccessType::Read), + resource_type: Some(ResourceType::File), + properties: Vec::new(), + }, + count: 2, + }], + total_occurrences: 2, + ..Default::default() + }; + let mut network = CaptureVerboseLoggingSummary::default(); + let mut bytes = 0; + network.record_with_byte_budget( + network_signature(CaptureVerboseLoggingOutcomeReason::Actionable), + &mut bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + + let document = CaptureVerboseLoggingDocument::new(&legacy, &network); + assert_eq!(document.version, CAPTURE_VERBOSE_LOGGING_VERSION); + assert_eq!(document.signatures.len(), 2); + assert_eq!(document.summary.total_occurrences, 3); + let network = document + .signatures + .iter() + .find(|aggregate| { + aggregate.signature.provider + == CaptureVerboseLoggingProvider::LearningModeNetworkDecision + }) + .unwrap(); + assert_eq!( + network.signature.configuration_recommendation, + Some(ConfigurationRecommendation::AddEgressAllow) + ); + } + + #[test] + fn parser_accepts_legacy_and_internal_versions() { + let legacy = VerboseLoggingDocument::new(&VerboseLoggingSummary::default()); + let legacy_bytes = serde_json::to_vec(&legacy).unwrap(); + assert_eq!( + parse_supported_verbose_document(&legacy_bytes) + .unwrap() + .version, + VerboseLoggingDocument::VERSION + ); + + let internal = CaptureVerboseLoggingDocument::new( + &VerboseLoggingSummary::default(), + &CaptureVerboseLoggingSummary::default(), + ); + let internal_bytes = serde_json::to_vec(&internal).unwrap(); + assert_eq!( + parse_supported_verbose_document(&internal_bytes) + .unwrap() + .version, + CAPTURE_VERBOSE_LOGGING_VERSION + ); + } +} diff --git a/src/mxc-sdk/src/core/learning_mode_core/mod.rs b/src/mxc-sdk/src/core/learning_mode_core/mod.rs index b090001dc..1b41efbec 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/mod.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/mod.rs @@ -33,6 +33,7 @@ #![deny(missing_docs)] pub mod analyze; +pub(crate) mod capture_diagnostics; pub mod emit; pub mod model; pub mod paired_output; diff --git a/src/mxc-sdk/src/core/learning_mode_core/model.rs b/src/mxc-sdk/src/core/learning_mode_core/model.rs index 68a0e6fa3..f181028fb 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/model.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/model.rs @@ -123,15 +123,18 @@ pub struct DeniedResource { /// Access type the workload was attempting (see [`AccessType`]). pub access_type: AccessType, - /// Process ID inside the sandbox that triggered the denial. + /// Process ID inside the sandbox that triggered the denial. Network + /// decisions use `0` when the normalized broker event does not contain a + /// reliable workload process ID. pub pid: u32, - /// Kernel timestamp of the denial. On Windows this is `FILETIME` - /// (100-nanosecond intervals since 1601-01-01 UTC), copied from - /// `EVENT_RECORD.EventHeader.TimeStamp`. Other backends normalise - /// their native clocks onto the same epoch so consumers can treat - /// the field uniformly. The JSON wire format uses a decimal string so - /// JavaScript consumers do not lose precision. + /// Timestamp of the denial. On Windows this is `FILETIME` (100-nanosecond + /// intervals since 1601-01-01 UTC). Most events use + /// `EVENT_RECORD.EventHeader.TimeStamp`; normalized network decisions use + /// the payload's original WFP timestamp because the broker emits the ETW + /// record later. Other backends normalise their native clocks onto the same + /// epoch so consumers can treat the field uniformly. The JSON wire format + /// uses a decimal string so JavaScript consumers do not lose precision. #[serde(with = "decimal_u64")] pub filetime: u64, } diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index 92f4d0807..b89621f50 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -20,20 +20,16 @@ pub const MAX_VERBOSE_LOGGING_SIGNATURE_BYTES: usize = 16 * 1024 * 1024; /// Stable category for a known Learning Mode ETW provider. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[non_exhaustive] pub enum VerboseLoggingProvider { /// Microsoft-Windows-Kernel-General. KernelGeneral, /// Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode. PrivacyAuditingPermissiveLearningMode, - /// Microsoft-Windows-LearningMode-NetworkDecision. - LearningModeNetworkDecision, } /// Closed reason describing how a decoder outcome was handled. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[non_exhaustive] pub enum VerboseLoggingOutcomeReason { /// The event produced a valid actionable denial. Actionable, @@ -63,14 +59,16 @@ pub enum VerboseLoggingOutcomeReason { ComInterfaceCall, /// The event was valid but did not describe an actionable denial. NotActionable, - /// The source reported an intentional explicit deny or allow-rule exclusion. - IntentionalNetworkPolicyDeny, - /// The source reported a direct connection blocked by proxy containment. - ProxyContainment, - /// The network event carried an unknown or unsupported stable reason. - UnknownNetworkReason, - /// The network event omitted endpoint data required for policy guidance. - IncompleteNetworkEndpoint, +} + +/// Validates a supported MXC verbose logging document without exposing its +/// version-specific internal Rust representation. +/// +/// This accepts the legacy public document and newer product-only document +/// versions used by MXC capture paths. +#[doc(hidden)] +pub fn validate_verbose_logging_document(bytes: &[u8]) -> Result<(), serde_json::Error> { + super::capture_diagnostics::parse_supported_verbose_document(bytes).map(|_| ()) } impl VerboseLoggingOutcomeReason { @@ -565,8 +563,8 @@ mod tests { let retained = summary.signatures[0].signature.clone(); summary.record_actionable_after_saturation(retained); summary.record_actionable_after_saturation(VerboseLoggingSignature { - provider: VerboseLoggingProvider::LearningModeNetworkDecision, - provider_guid: "network".to_string(), + provider: VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, + provider_guid: "privacy".to_string(), event_id: u16::MAX, reason: VerboseLoggingOutcomeReason::Actionable, pid: 0, diff --git a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs index 085dd4791..c89e81c3e 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/capability_dacl.rs @@ -270,7 +270,7 @@ pub(crate) fn extract_denials( access_type: AccessType::Unknown, filetime, event_id: parts.event_id, - provider, + provider: Some(provider), verbose_logging_properties: crate::learning_mode_windows::extractors::sanitize_properties(&parts.props), }) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 04b9865ab..0e22544ca 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -29,6 +29,10 @@ use std::os::windows::ffi::OsStrExt; use std::path::Path; use crate::learning_mode_core::{ + capture_diagnostics::{ + CaptureAnalysis, CaptureVerboseLoggingOutcomeReason, CaptureVerboseLoggingProvider, + CaptureVerboseLoggingSignature, CaptureVerboseLoggingSummary, + }, AnalysisResult, AnalyzeError, DenialAnalyzer, DeniedResource, ProcessLifetime, VerboseLoggingOutcomeReason, VerboseLoggingProvider, VerboseLoggingSignature, VerboseLoggingSummary, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, @@ -150,6 +154,9 @@ struct Accumulator<'visitor> { verbose_logging_signature_bytes: usize, skip_relog_header: bool, verbose_logging_actionable_only_saturated: bool, + network_verbose_logging: CaptureVerboseLoggingSummary, + network_verbose_logging_signature_bytes: usize, + network_verbose_logging_actionable_only_saturated: bool, } impl<'visitor> Accumulator<'visitor> { @@ -175,6 +182,9 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging_signature_bytes: 0, skip_relog_header: false, verbose_logging_actionable_only_saturated: false, + network_verbose_logging: CaptureVerboseLoggingSummary::default(), + network_verbose_logging_signature_bytes: 0, + network_verbose_logging_actionable_only_saturated: false, } } @@ -207,6 +217,9 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging_signature_bytes: 0, skip_relog_header: false, verbose_logging_actionable_only_saturated: false, + network_verbose_logging: CaptureVerboseLoggingSummary::default(), + network_verbose_logging_signature_bytes: 0, + network_verbose_logging_actionable_only_saturated: false, } } @@ -232,18 +245,21 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging_signature_bytes: 0, skip_relog_header: false, verbose_logging_actionable_only_saturated: false, + network_verbose_logging: CaptureVerboseLoggingSummary::default(), + network_verbose_logging_signature_bytes: 0, + network_verbose_logging_actionable_only_saturated: false, } } - fn selects(&self, provider: windows::core::GUID, event_id: u16) -> bool { - if self.process_lifetimes.is_some() { - is_process_scoped_event(provider, event_id) - } else { - is_learning_mode_event(provider, event_id) - } + fn add_raw_denial(&mut self, raw: RawDenial) { + self.add_raw_denial_impl(raw, true); } - fn add_raw_denial(&mut self, raw: RawDenial, event_name: Option<&str>) { + fn add_network_denial(&mut self, raw: RawDenial) { + self.add_raw_denial_impl(raw, false); + } + + fn add_raw_denial_impl(&mut self, raw: RawDenial, record_legacy_verbose: bool) { if !self.event_in_scope(raw.pid, raw.filetime) { return; } @@ -251,12 +267,13 @@ impl<'visitor> Accumulator<'visitor> { match path_norm::to_user_visible(&raw.object_name) { Some(resource) if path_norm::is_user_visible_absolute(&resource) => resource, Some(candidate) => { - self.record_raw_denial_outcome( - &raw, - &candidate, - VerboseLoggingOutcomeReason::UnusableResourcePath, - event_name, - ); + if record_legacy_verbose { + self.record_raw_denial_outcome( + &raw, + &candidate, + VerboseLoggingOutcomeReason::UnusableResourcePath, + ); + } return; } None if path_norm::is_user_visible_absolute(&raw.object_name) => { @@ -264,24 +281,26 @@ impl<'visitor> Accumulator<'visitor> { } None => { let candidate = raw.object_name.clone(); - self.record_raw_denial_outcome( - &raw, - &candidate, - VerboseLoggingOutcomeReason::UnusableResourcePath, - event_name, - ); + if record_legacy_verbose { + self.record_raw_denial_outcome( + &raw, + &candidate, + VerboseLoggingOutcomeReason::UnusableResourcePath, + ); + } return; } } } else { path_norm::to_user_visible(&raw.object_name).unwrap_or_else(|| raw.object_name.clone()) }; - self.record_raw_denial_outcome( - &raw, - &resource, - VerboseLoggingOutcomeReason::Actionable, - event_name, - ); + if record_legacy_verbose { + self.record_raw_denial_outcome( + &raw, + &resource, + VerboseLoggingOutcomeReason::Actionable, + ); + } let dedup_resource = match raw.resource_type { crate::learning_mode_core::ResourceType::File | crate::learning_mode_core::ResourceType::Other => resource.to_ascii_lowercase(), @@ -300,6 +319,7 @@ impl<'visitor> Accumulator<'visitor> { if self.denials.len() >= MAX_UNIQUE_DENIALS { self.truncated = true; self.verbose_logging.mark_actionable_limit_reached(); + self.network_verbose_logging.actionable_limit_reached = true; return; } self.seen.insert((dedup_resource, raw.access_type)); @@ -359,9 +379,12 @@ impl<'visitor> Accumulator<'visitor> { let properties = crate::learning_mode_windows::extractors::bound_properties( properties.into_iter().collect::>(), ); + let provider = raw + .provider + .expect("legacy raw denials always carry a legacy provider"); self.record_outcome( - raw.provider, - (raw.event_id, event_name), + provider, + raw.event_id, reason, raw.pid, (Some(raw.access_type), Some(raw.resource_type)), @@ -372,7 +395,7 @@ impl<'visitor> Accumulator<'visitor> { fn record_outcome( &mut self, provider: VerboseLoggingProvider, - event: (u16, Option<&str>), + event_id: u16, reason: VerboseLoggingOutcomeReason, pid: u32, classification: ( @@ -387,8 +410,7 @@ impl<'visitor> Accumulator<'visitor> { provider_guid: crate::learning_mode_windows::extractors::verbose_logging_provider_guid( provider, ), - event_id: event.0, - event_name: crate::learning_mode_windows::extractors::sanitize_event_name(event.1), + event_id, reason, pid, access_type, @@ -400,6 +422,7 @@ impl<'visitor> Accumulator<'visitor> { .record_actionable_after_saturation(signature); return; } + let overflow_before = self.verbose_logging.overflow_occurrences; self.verbose_logging.record_with_byte_budget( signature, @@ -418,6 +441,51 @@ impl<'visitor> Accumulator<'visitor> { } } + fn record_network_outcome( + &mut self, + event_id: u16, + event_name: Option, + analysis: &crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis, + ) { + let (access_type, resource_type) = analysis.classification; + let signature = CaptureVerboseLoggingSignature { + provider: CaptureVerboseLoggingProvider::LearningModeNetworkDecision, + provider_guid: "{71237669-21C3-4101-BD2F-FF38945D725A}".to_string(), + event_id, + event_name, + reason: analysis.reason, + pid: 0, + access_type, + resource_type, + network_decision_reason: analysis.network_decision_reason, + configuration_recommendation: analysis.configuration_recommendation, + network_endpoint: analysis.network_endpoint.clone(), + properties: analysis.properties.clone(), + }; + if self.network_verbose_logging_actionable_only_saturated && analysis.reason.is_actionable() + { + self.network_verbose_logging + .record_actionable_after_saturation(signature); + return; + } + let overflow_before = self.network_verbose_logging.overflow_occurrences; + self.network_verbose_logging.record_with_byte_budget( + signature, + &mut self.network_verbose_logging_signature_bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + if analysis.reason.is_actionable() + && self.network_verbose_logging.overflow_occurrences > overflow_before + && self + .network_verbose_logging + .signatures + .iter() + .all(|group| group.signature.reason.is_actionable()) + { + self.network_verbose_logging_actionable_only_saturated = true; + } + } + fn event_in_scope(&self, pid: u32, filetime: u64) -> bool { self.process_lifetimes .as_ref() @@ -429,6 +497,7 @@ impl<'visitor> Accumulator<'visitor> { self.processing_limit_reached = true; self.truncated = true; self.verbose_logging.mark_processed_events_truncated(); + self.network_verbose_logging.processed_events_truncated = true; self.stop_requested = true; return false; } @@ -457,7 +526,7 @@ impl<'visitor> Accumulator<'visitor> { pid: u32, error: tdh_decode::DecodeError, ) { - let fatal = matches!(self.mode, CollectionMode::Raw); + let fatal = matches!(self.mode, CollectionMode::Raw) || error.is_schema_error(); if fatal { if self.decode_error.is_none() { self.decode_error = @@ -465,35 +534,63 @@ impl<'visitor> Accumulator<'visitor> { } return; } - if !is_learning_mode_event(provider, event_id) { - return; - } - let pid = if is_process_scoped_event(provider, event_id) { - pid - } else { - 0 - }; - let reason = match error.event_kind() { - Some(tdh_decode::EventDecodeKind::PayloadMalformed) => { - VerboseLoggingOutcomeReason::EventPayloadMalformed - } - Some(tdh_decode::EventDecodeKind::DecoderLimitReached) => { - VerboseLoggingOutcomeReason::DecoderLimitReached - } - Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { - VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding - } - None => VerboseLoggingOutcomeReason::SchemaUnavailable, - }; - if reason == VerboseLoggingOutcomeReason::SchemaUnavailable - && is_process_scoped_event(provider, event_id) - { - self.truncated = true; - } - let event = (event_id, error.event_name()); - if let Some(category) = + if provider == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER { + let reason = match error.event_kind() { + Some(tdh_decode::EventDecodeKind::PayloadMalformed) => { + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed + } + Some(tdh_decode::EventDecodeKind::DecoderLimitReached) => { + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached + } + Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + None => return, + }; + let event_name = error + .event_name() + .and_then(|name| { + crate::learning_mode_windows::extractors::bound_properties(vec![( + "EventName".to_string(), + name.to_string(), + )]) + .into_iter() + .next() + }) + .map(|(_, value)| value); + self.record_network_outcome( + event_id, + event_name, + &crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis { + denial: None, + reason, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + classification: (None, None), + properties: Vec::new(), + }, + ); + } else if let Some(category) = crate::learning_mode_windows::extractors::verbose_logging_provider_for_guid(provider) { + let reason = match error.event_kind() { + Some(tdh_decode::EventDecodeKind::PayloadMalformed) => { + VerboseLoggingOutcomeReason::EventPayloadMalformed + } + Some(tdh_decode::EventDecodeKind::DecoderLimitReached) => { + VerboseLoggingOutcomeReason::DecoderLimitReached + } + Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { + VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + None => return, + }; + let properties = error + .event_name() + .map(|name| vec![("EventName".to_string(), name.to_string())]) + .map(crate::learning_mode_windows::extractors::bound_properties) + .unwrap_or_default(); let classification = match event_id { crate::learning_mode_windows::extractors::LEARNING_MODE_VIOLATION_EVENT_ID => ( Some(crate::learning_mode_core::AccessType::Unknown), @@ -514,11 +611,11 @@ impl<'visitor> Accumulator<'visitor> { } _ => (None, None), }; - self.record_outcome(category, event, reason, pid, classification, Vec::new()); + self.record_outcome(category, event_id, reason, pid, classification, properties); } } - fn into_analysis(self) -> Result { + fn into_capture_analysis(self) -> Result { if let Some(error) = self.decode_error { return Err(AnalyzeError::Decode(error)); } @@ -527,11 +624,16 @@ impl<'visitor> Accumulator<'visitor> { "relogged trace has no transport header".into(), )); } - let mut result = AnalysisResult { + Ok(CaptureAnalysis { denials: self.denials, denied_resources_truncated: self.truncated, verbose_logging: self.verbose_logging, - }; + network_verbose_logging: self.network_verbose_logging, + }) + } + + fn into_analysis(self) -> Result { + let mut result = self.into_capture_analysis()?.into_legacy(); match result.fit_verbose_logging_within_serialized_bytes( crate::learning_mode_windows::guarded_wpr_protocol::MAX_ANALYSIS_BYTES as usize, ) { @@ -551,6 +653,20 @@ impl<'visitor> Accumulator<'visitor> { pub struct EtlDenialAnalyzer; impl EtlDenialAnalyzer { + /// Analyzes a native capture with MXC's complete internal diagnostics. + /// + /// # Errors + /// + /// Returns [`AnalyzeError`] if the trace cannot be opened or decoded. + pub(crate) fn analyze_capture( + &self, + source_path: &Path, + ) -> Result { + let mut accumulator = Accumulator::analyze(); + process_trace_file(source_path, &mut accumulator)?; + accumulator.into_capture_analysis() + } + /// Analyzes only events belonging to the supplied process lifetimes. /// /// This is the mandatory decode path for host-wide WPR fallback traces. @@ -609,9 +725,18 @@ impl EtlDenialAnalyzer { impl DenialAnalyzer for EtlDenialAnalyzer { fn analyze(&self, source_path: &Path) -> Result { - let mut accumulator = Accumulator::analyze(); - process_trace_file(source_path, &mut accumulator)?; - accumulator.into_analysis() + let mut result = self.analyze_capture(source_path)?.into_legacy(); + match result.fit_verbose_logging_within_serialized_bytes( + crate::learning_mode_windows::guarded_wpr_protocol::MAX_ANALYSIS_BYTES as usize, + ) { + Ok(true) => Ok(result), + Ok(false) => Err(AnalyzeError::Decode( + "actionable Learning Mode analysis exceeds the guarded transport limit".to_string(), + )), + Err(error) => Err(AnalyzeError::Decode(format!( + "failed to size Learning Mode analysis: {error}" + ))), + } } } @@ -624,7 +749,7 @@ impl DenialAnalyzer for EtlDenialAnalyzer { /// provider manifests registered on the machine). #[cfg(test)] fn resources_from_events(events: &[CollectedEvent]) -> AnalysisResult { - resources_from_events_for_process_lifetimes(events, None) + capture_resources_from_events_for_process_lifetimes(events, None).into_legacy() } #[cfg(test)] @@ -632,13 +757,26 @@ fn resources_from_events_for_process_lifetimes( events: &[CollectedEvent], process_lifetimes: Option<&[ProcessLifetime]>, ) -> AnalysisResult { + capture_resources_from_events_for_process_lifetimes(events, process_lifetimes).into_legacy() +} + +#[cfg(test)] +fn capture_resources_from_events(events: &[CollectedEvent]) -> CaptureAnalysis { + capture_resources_from_events_for_process_lifetimes(events, None) +} + +#[cfg(test)] +fn capture_resources_from_events_for_process_lifetimes( + events: &[CollectedEvent], + process_lifetimes: Option<&[ProcessLifetime]>, +) -> CaptureAnalysis { let mut accumulator = match process_lifetimes { Some(lifetimes) => Accumulator::analyze_for_process_lifetimes(lifetimes), None => Accumulator::analyze(), }; accumulate_collected_events(events, &mut accumulator); accumulator - .into_analysis() + .into_capture_analysis() .expect("pure denial accumulation cannot decode-fail") } @@ -892,16 +1030,49 @@ unsafe fn process_event_record(event_record: *mut EVENT_RECORD, acc: &mut Accumu let mut analyze_filetime = None; if matches!(acc.mode, CollectionMode::Analyze) { - if !acc.selects(provider, event_id) { + let category = + crate::learning_mode_windows::extractors::verbose_logging_provider_for_guid(provider); + let is_network = + provider == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER; + if category.is_none() && !is_network { + // Unrelated provider: unrelated host traffic, ignored entirely + // (not aggregated as an excluded Learning Mode outcome). return; } let Some(filetime) = normalized_filetime(header.TimeStamp, acc) else { return; }; analyze_filetime = Some(filetime); - let brokered = - event_id == crate::learning_mode_windows::extractors::CAPABILITY_DENIAL_EVENT_ID; - if !brokered && !acc.event_in_scope(header.ProcessId, filetime) { + if !is_learning_mode_event(provider, event_id) { + if !acc.event_in_scope(header.ProcessId, filetime) || !acc.begin_event() { + return; + } + // A known provider, but outside its supported event vocabulary: + // aggregate (as a signature with no decoded properties) without + // paying for a TDH decode. + if is_network { + acc.record_network_outcome( + event_id, + None, + &crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis { + denial: None, + reason: CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + classification: (None, None), + properties: Vec::new(), + }, + ); + } else { + acc.record_exclusion( + category.expect("legacy provider category was checked"), + event_id, + VerboseLoggingOutcomeReason::UnsupportedEventSchema, + header.ProcessId, + Vec::new(), + ); + } return; } } @@ -1054,10 +1225,27 @@ fn handle_decoded_event( filetime: u64, acc: &mut Accumulator<'_>, ) { - if !acc.selects(parts.provider, parts.event_id) { + if parts.provider == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER + { + if !acc.event_in_scope(0, filetime) || !acc.begin_event() { + return; + } + let analysis = + crate::learning_mode_windows::network_extractors::analyze_network_decision(parts); + let denial = analysis.denial.clone(); + acc.record_network_outcome( + parts.event_id, + (parts.event_id + == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_EVENT_ID) + .then(|| "NetworkDecisionV1".to_string()), + &analysis, + ); + if let Some(raw) = denial { + acc.add_network_denial(raw); + } return; } - let event = (parts.event_id, parts.event_name.as_deref()); + let Some(category) = crate::learning_mode_windows::extractors::verbose_logging_provider_for_guid(parts.provider) else { @@ -1069,7 +1257,7 @@ fn handle_decoded_event( if acc.event_in_scope(header_pid, filetime) && acc.begin_event() { acc.record_outcome( category, - event, + parts.event_id, VerboseLoggingOutcomeReason::EventPayloadMalformed, header_pid, crate::learning_mode_windows::extractors::verbose_logging_classification(parts), @@ -1091,18 +1279,18 @@ fn handle_decoded_event( let capability_candidates = crate::learning_mode_windows::capability_dacl::extract_denials(parts, pid, filetime); for raw in capability_candidates.iter().cloned() { - acc.add_raw_denial(raw, event.1); + acc.add_raw_denial(raw); } match primary { - Ok(raw) => acc.add_raw_denial(raw, event.1), + Ok(raw) => acc.add_raw_denial(raw), Err(reason) => { let recovered_by_dacl = reason == VerboseLoggingOutcomeReason::UnresolvedCapability && !capability_candidates.is_empty(); if !recovered_by_dacl { acc.record_outcome( category, - event, + parts.event_id, reason, pid, crate::learning_mode_windows::extractors::verbose_logging_classification(parts), @@ -2033,8 +2221,9 @@ mod tests { access_type: access, filetime: 1, event_id: 4907, - provider: + provider: Some( crate::learning_mode_core::VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, + ), verbose_logging_properties: Vec::new(), } } @@ -2380,12 +2569,46 @@ mod tests { } fn network_event(pid: u32, filetime: u64, kv: &[(&str, &str)]) -> CollectedEvent { + let mut properties = vec![ + ("SchemaVersion", "1"), + ("SourceDomain", "0"), + ("Mode", "1"), + ("NormalDecision", "1"), + ("EffectiveDecision", "1"), + ("Reason", "0"), + ("FieldFlags", "0"), + ("OriginalTimestamp", "500"), + ("UserSid", "S-1-5-21-1"), + ("PackageSid", "S-1-15-2-1"), + ("ApplicationId", ""), + ("WfpEventType", "0"), + ("FilterId", "9001"), + ("ProviderGuid", "{00000000-0000-0000-0000-000000000000}"), + ("SublayerGuid", "{00000000-0000-0000-0000-000000000000}"), + ("LayerId", "0"), + ("Direction", "0"), + ("IsLoopback", "0"), + ("Protocol", "0"), + ("LocalAddress", ""), + ("LocalPort", "0"), + ("RemoteAddress", ""), + ("RemotePort", "0"), + ("CapabilityId", "0"), + ]; + for (name, value) in kv { + if let Some((_, current)) = properties + .iter_mut() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + { + *current = value; + } + } event_with_provider( crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER, crate::learning_mode_windows::network_extractors::NETWORK_DECISION_EVENT_ID, pid, filetime, - kv, + &properties, ) } @@ -2440,7 +2663,7 @@ mod tests { network_event(777, 601, &tessera), network_event(777, 602, &explicit_deny), ]; - let analysis = resources_from_events(&events); + let analysis = capture_resources_from_events(&events); assert_eq!(analysis.denials.len(), 2); assert_eq!(analysis.denials[0].resource, "internetClient"); @@ -2449,12 +2672,13 @@ mod tests { assert_eq!(analysis.denials[0].filetime, 500); assert_eq!(analysis.denials[1].resource, "tcp://203.0.113.10:443"); let network_signature = analysis - .verbose_logging + .network_verbose_logging .signatures .iter() .find(|aggregate| { - aggregate.signature.provider == VerboseLoggingProvider::LearningModeNetworkDecision - && aggregate.signature.reason == VerboseLoggingOutcomeReason::Actionable + aggregate.signature.provider + == CaptureVerboseLoggingProvider::LearningModeNetworkDecision + && aggregate.signature.reason == CaptureVerboseLoggingOutcomeReason::Actionable && aggregate.signature.resource_type == Some(ResourceType::Network) }) .expect("actionable network signature"); @@ -2469,11 +2693,29 @@ mod tests { .properties .iter() .all(|(_, value)| value != r"\Device\HarddiskVolume3\app.exe")); - assert!(analysis.verbose_logging.signatures.iter().any(|aggregate| { - aggregate.signature.provider == VerboseLoggingProvider::LearningModeNetworkDecision - && aggregate.signature.reason - == VerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny + assert!(analysis + .network_verbose_logging + .signatures + .iter() + .any(|aggregate| { + aggregate.signature.provider + == CaptureVerboseLoggingProvider::LearningModeNetworkDecision + && aggregate.signature.reason + == CaptureVerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny + && aggregate.signature.network_decision_reason + == Some( + crate::learning_mode_core::capture_diagnostics::NetworkDecisionReason::AuthoredExplicitDeny + ) + && aggregate.signature.configuration_recommendation + == Some( + crate::learning_mode_core::capture_diagnostics::ConfigurationRecommendation::ReviewEgressDeny + ) })); + + let legacy = capture_resources_from_events(&events).into_legacy(); + assert_eq!(legacy.denials.len(), 2); + assert!(legacy.verbose_logging.signatures.is_empty()); + assert_eq!(legacy.verbose_logging.overflow_occurrences, 3); } #[test] @@ -2495,24 +2737,27 @@ mod tests { .into_iter() .chain([("Reason", "65535"), ("FieldFlags", "16")]) .collect::>(); - let mut malformed = common + let malformed = common .into_iter() - .chain([("Reason", "100"), ("FieldFlags", "16")]) + .chain([ + ("Reason", "100"), + ("FieldFlags", "16"), + ("Direction", "invalid"), + ]) .collect::>(); - malformed.retain(|(name, _)| *name != "Direction"); - let analysis = resources_from_events(&[ + let analysis = capture_resources_from_events(&[ network_event(777, 600, &unknown_reason), network_event(777, 601, &malformed), ]); assert!(analysis.denials.is_empty()); for expected in [ - VerboseLoggingOutcomeReason::UnknownNetworkReason, - VerboseLoggingOutcomeReason::EventPayloadMalformed, + CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason, + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed, ] { let aggregate = analysis - .verbose_logging + .network_verbose_logging .signatures .iter() .find(|aggregate| aggregate.signature.reason == expected) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 5497cf174..c36be16ef 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -119,7 +119,7 @@ pub struct RawDenial { pub event_id: u16, /// Symbolic category of the originating provider, for verbose logging /// aggregation. Never a raw provider GUID. - pub provider: VerboseLoggingProvider, + pub provider: Option, /// Bounded sensitive-value-redacted properties retained for verbose logging signatures. pub verbose_logging_properties: Vec<(String, String)>, } @@ -146,10 +146,6 @@ pub fn extract_denial( if !is_learning_mode_event(parts.provider, parts.event_id) { return Err(VerboseLoggingOutcomeReason::UnsupportedEventSchema); } - if parts.provider == super::network_extractors::NETWORK_DECISION_PROVIDER { - return super::network_extractors::extract_network_denial(parts); - } - match parts.event_id { ACCESS_CHECK_EVENT_ID | PRIVACY_ACCESS_CHECK_EVENT_ID => { build_denial_from_access_check(parts, pid, filetime, provider) @@ -285,8 +281,6 @@ pub(crate) fn verbose_logging_provider_for_guid(provider: GUID) -> Option VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { format_guid_braced_uppercase(PRIVACY_LEARNING_MODE_PROVIDER) } - VerboseLoggingProvider::LearningModeNetworkDecision => { - format_guid_braced_uppercase(NETWORK_DECISION_PROVIDER) - } } } @@ -791,7 +782,7 @@ pub fn build_denial_from_access_check( access_type, filetime, event_id: parts.event_id, - provider, + provider: Some(provider), verbose_logging_properties: sanitize_properties(&parts.props), }) } @@ -881,7 +872,7 @@ pub fn build_denial_from_learning_mode( access_type: AccessType::Unknown, filetime, event_id: parts.event_id, - provider, + provider: Some(provider), verbose_logging_properties: sanitize_properties(&parts.props), }) } @@ -948,7 +939,7 @@ pub fn build_denial_from_capability( access_type: AccessType::Unknown, filetime, event_id: parts.event_id, - provider, + provider: Some(provider), verbose_logging_properties: sanitize_properties(&parts.props), }) } @@ -1680,7 +1671,7 @@ mod tests { // pid comes from the payload ProcessId (0x1acc), not the header. assert_eq!(ev.pid, 0x1acc); assert_eq!(ev.object_name, "internetClient"); - assert_eq!(ev.provider, VerboseLoggingProvider::KernelGeneral); + assert_eq!(ev.provider, Some(VerboseLoggingProvider::KernelGeneral)); } #[test] diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 2812e53aa..7aeef37b3 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -4,8 +4,11 @@ //! Extractors for the OS-managed Learning Mode WFP decision event. use crate::learning_mode_core::{ - AccessType, ResourceType, VerboseLoggingOutcomeReason as VerboseLoggingExclusionReason, - VerboseLoggingProvider, + capture_diagnostics::{ + CaptureVerboseLoggingOutcomeReason, ConfigurationRecommendation, NetworkDecisionReason, + NetworkEndpoint, + }, + AccessType, ResourceType, }; use std::net::IpAddr; use windows::core::GUID; @@ -44,11 +47,73 @@ const TESSERA_PROVIDER: &str = "{2F8C6D14-3B7E-4A59-9C08-1D4E7A6B2F30}"; const TESSERA_SUBLAYER: &str = "{7B1E9A2C-9D4F-4C8A-B321-5E6D2F8A1C44}"; const APP_ISOLATION_SUBLAYER: &str = "{FFE221C3-92A8-4564-A59F-DAFB70756020}"; -pub(crate) fn extract_network_denial( +const NETWORK_DECISION_V1_FIELDS: [&str; 24] = [ + "SchemaVersion", + "SourceDomain", + "Mode", + "NormalDecision", + "EffectiveDecision", + "Reason", + "FieldFlags", + "OriginalTimestamp", + "UserSid", + "PackageSid", + "ApplicationId", + "WfpEventType", + "FilterId", + "ProviderGuid", + "SublayerGuid", + "LayerId", + "Direction", + "IsLoopback", + "Protocol", + "LocalAddress", + "LocalPort", + "RemoteAddress", + "RemotePort", + "CapabilityId", +]; + +pub(crate) struct NetworkDecisionAnalysis { + pub(crate) denial: Option, + pub(crate) reason: CaptureVerboseLoggingOutcomeReason, + pub(crate) network_decision_reason: Option, + pub(crate) configuration_recommendation: Option, + pub(crate) network_endpoint: Option, + pub(crate) classification: (Option, Option), + pub(crate) properties: Vec<(String, String)>, +} + +pub(crate) fn analyze_network_decision(parts: &DecodedEventParts) -> NetworkDecisionAnalysis { + let properties = sanitize_properties(&parts.props); + match analyze_network_decision_inner(parts) { + Ok(mut analysis) => { + analysis.properties = properties; + analysis + } + Err(reason) => NetworkDecisionAnalysis { + denial: None, + reason, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + classification: verbose_logging_classification(parts), + properties, + }, + } +} + +fn analyze_network_decision_inner( parts: &DecodedEventParts, -) -> Result { +) -> Result { if parts.provider != NETWORK_DECISION_PROVIDER || parts.event_id != NETWORK_DECISION_EVENT_ID { - return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + return Err(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); + } + if !NETWORK_DECISION_V1_FIELDS + .iter() + .all(|name| property(parts, name).is_some()) + { + return Err(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); } let schema_version = required_u16(parts, "SchemaVersion")?; @@ -68,7 +133,7 @@ pub(crate) fn extract_network_denial( || filetime == 0 || filter_id == 0 { - return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + return Err(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); } match source_domain { @@ -76,7 +141,7 @@ pub(crate) fn extract_network_denial( extract_app_isolation(parts, reason, field_flags, filetime, filter_id) } SOURCE_TESSERA => extract_tessera(parts, reason, field_flags, filetime, filter_id), - _ => Err(VerboseLoggingExclusionReason::UnknownNetworkReason), + _ => Err(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason), } } @@ -100,32 +165,67 @@ fn extract_app_isolation( field_flags: u32, filetime: u64, _filter_id: u64, -) -> Result { +) -> Result { if !property_eq(parts, "SublayerGuid", APP_ISOLATION_SUBLAYER) { - return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + return Err(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); } if reason != REASON_APP_ISOLATION_MISSING_CAPABILITY { - return Err(VerboseLoggingExclusionReason::UnknownNetworkReason); + return Err(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason); } if field_flags & FIELD_CAPABILITY_ID == 0 { - return Err(VerboseLoggingExclusionReason::UnresolvedCapability); + return Ok(network_diagnostic( + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability, + NetworkDecisionReason::AppIsolationMissingCapability, + None, + None, + ResourceType::Capability, + )); } - let capability = match required_u32(parts, "CapabilityId")? { + let capability_id = match required_u32(parts, "CapabilityId") { + Ok(capability_id) => capability_id, + Err(reason) => { + return Ok(network_diagnostic( + reason, + NetworkDecisionReason::AppIsolationMissingCapability, + None, + None, + ResourceType::Capability, + )); + } + }; + let capability = match capability_id { 0 => "internetClient", 1 => "internetClientServer", 2 => "privateNetworkClientServer", - _ => return Err(VerboseLoggingExclusionReason::UnresolvedCapability), + _ => { + return Ok(network_diagnostic( + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability, + NetworkDecisionReason::AppIsolationMissingCapability, + None, + None, + ResourceType::Capability, + )); + } }; - Ok(RawDenial { + let denial = RawDenial { pid: 0, resource_type: ResourceType::Capability, object_name: capability.to_string(), access_type: AccessType::Unknown, filetime, event_id: parts.event_id, - provider: VerboseLoggingProvider::LearningModeNetworkDecision, - verbose_logging_properties: sanitize_properties(&parts.props), + provider: None, + verbose_logging_properties: Vec::new(), + }; + Ok(NetworkDecisionAnalysis { + denial: Some(denial), + reason: CaptureVerboseLoggingOutcomeReason::Actionable, + network_decision_reason: Some(NetworkDecisionReason::AppIsolationMissingCapability), + configuration_recommendation: Some(ConfigurationRecommendation::AddCapability), + network_endpoint: None, + classification: (Some(AccessType::Unknown), Some(ResourceType::Capability)), + properties: Vec::new(), }) } @@ -135,51 +235,168 @@ fn extract_tessera( field_flags: u32, filetime: u64, _filter_id: u64, -) -> Result { +) -> Result { if !property_eq(parts, "ProviderGuid", TESSERA_PROVIDER) || !property_eq(parts, "SublayerGuid", TESSERA_SUBLAYER) || field_flags & FIELD_CAPABILITY_ID != 0 { - return Err(VerboseLoggingExclusionReason::UnsupportedEventSchema); + return Err(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); } - match reason { - REASON_TESSERA_EXPLICIT_DENY | REASON_TESSERA_ALLOW_EXCLUSION => { - return Err(VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny) - } - REASON_TESSERA_PROXY_CONTAINMENT => { - return Err(VerboseLoggingExclusionReason::ProxyContainment) + let (network_decision_reason, outcome_reason, recommendation) = match reason { + REASON_TESSERA_DIRECT_DEFAULT_DENY => ( + NetworkDecisionReason::DirectDefaultDeny, + CaptureVerboseLoggingOutcomeReason::Actionable, + Some(ConfigurationRecommendation::AddEgressAllow), + ), + REASON_TESSERA_EXPLICIT_DENY => ( + NetworkDecisionReason::AuthoredExplicitDeny, + CaptureVerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny, + Some(ConfigurationRecommendation::ReviewEgressDeny), + ), + REASON_TESSERA_ALLOW_EXCLUSION => ( + NetworkDecisionReason::AllowExclusion, + CaptureVerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny, + Some(ConfigurationRecommendation::ReviewAllowExclusion), + ), + REASON_TESSERA_PROXY_CONTAINMENT => ( + NetworkDecisionReason::ProxyContainment, + CaptureVerboseLoggingOutcomeReason::ProxyContainment, + Some(ConfigurationRecommendation::UseConfiguredProxy), + ), + _ => return Err(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason), + }; + + let endpoint = match exact_network_endpoint(parts, field_flags) { + Ok(endpoint) => endpoint, + Err(reason) => { + return Ok(network_diagnostic( + reason, + network_decision_reason, + recommendation, + None, + ResourceType::Network, + )); } - REASON_TESSERA_DIRECT_DEFAULT_DENY => {} - _ => return Err(VerboseLoggingExclusionReason::UnknownNetworkReason), + }; + if reason != REASON_TESSERA_DIRECT_DEFAULT_DENY { + return Ok(NetworkDecisionAnalysis { + denial: None, + reason: outcome_reason, + network_decision_reason: Some(network_decision_reason), + configuration_recommendation: recommendation, + network_endpoint: endpoint, + classification: (Some(AccessType::Unknown), Some(ResourceType::Network)), + properties: Vec::new(), + }); } if field_flags & FIELD_REMOTE_ADDRESS == 0 { - return Err(VerboseLoggingExclusionReason::IncompleteNetworkEndpoint); + return Ok(network_diagnostic( + CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint, + network_decision_reason, + None, + None, + ResourceType::Network, + )); } + let remote_address = match required_ip_string(parts, "RemoteAddress") { + Ok(remote_address) => remote_address, + Err(reason) => { + return Ok(network_diagnostic( + reason, + network_decision_reason, + None, + None, + ResourceType::Network, + )); + } + }; - let remote_address = required_ip_string(parts, "RemoteAddress")?; + let denial = (|| { + let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; + let _local_address = + optional_ip_string(parts, field_flags, FIELD_LOCAL_ADDRESS, "LocalAddress")?; + let _local_port = optional_u16(parts, field_flags, FIELD_LOCAL_PORT, "LocalPort")?; + let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; + let _application_id = + optional_string(parts, field_flags, FIELD_APPLICATION_ID, "ApplicationId")?; + let _direction = required_u32(parts, "Direction")?; + let resource = format_network_resource(protocol, &remote_address, remote_port); + + Ok::<_, CaptureVerboseLoggingOutcomeReason>(RawDenial { + pid: 0, + resource_type: ResourceType::Network, + object_name: resource, + access_type: AccessType::Unknown, + filetime, + event_id: parts.event_id, + provider: None, + verbose_logging_properties: Vec::new(), + }) + })(); + let denial = match denial { + Ok(denial) => denial, + Err(reason) => { + return Ok(network_diagnostic( + reason, + network_decision_reason, + None, + endpoint, + ResourceType::Network, + )); + } + }; + Ok(NetworkDecisionAnalysis { + denial: Some(denial), + reason: outcome_reason, + network_decision_reason: Some(network_decision_reason), + configuration_recommendation: recommendation.filter(|_| endpoint.is_some()), + network_endpoint: endpoint, + classification: (Some(AccessType::Unknown), Some(ResourceType::Network)), + properties: Vec::new(), + }) +} + +fn network_diagnostic( + reason: CaptureVerboseLoggingOutcomeReason, + network_decision_reason: NetworkDecisionReason, + configuration_recommendation: Option, + network_endpoint: Option, + resource_type: ResourceType, +) -> NetworkDecisionAnalysis { + NetworkDecisionAnalysis { + denial: None, + reason, + network_decision_reason: Some(network_decision_reason), + configuration_recommendation, + network_endpoint, + classification: (Some(AccessType::Unknown), Some(resource_type)), + properties: Vec::new(), + } +} +fn exact_network_endpoint( + parts: &DecodedEventParts, + field_flags: u32, +) -> Result, CaptureVerboseLoggingOutcomeReason> { + if field_flags & FIELD_REMOTE_ADDRESS == 0 { + return Ok(None); + } + let remote_address = required_ip_string(parts, "RemoteAddress")?; let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; - let _local_address = - optional_ip_string(parts, field_flags, FIELD_LOCAL_ADDRESS, "LocalAddress")?; - let _local_port = optional_u16(parts, field_flags, FIELD_LOCAL_PORT, "LocalPort")?; let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; - let _application_id = - optional_string(parts, field_flags, FIELD_APPLICATION_ID, "ApplicationId")?; - let _direction = required_u32(parts, "Direction")?; - let resource = format_network_resource(protocol, &remote_address, remote_port); - - Ok(RawDenial { - pid: 0, - resource_type: ResourceType::Network, - object_name: resource, - access_type: AccessType::Unknown, - filetime, - event_id: parts.event_id, - provider: VerboseLoggingProvider::LearningModeNetworkDecision, - verbose_logging_properties: sanitize_properties(&parts.props), - }) + let protocol = match protocol { + Some(6) => "tcp", + Some(17) => "udp", + Some(1 | 58) => "icmp", + _ => return Ok(None), + }; + Ok(Some(NetworkEndpoint { + protocol: protocol.to_string(), + remote_address, + remote_port, + })) } fn format_network_resource(protocol: Option, address: &str, port: Option) -> String { @@ -217,56 +434,56 @@ fn property_eq(parts: &DecodedEventParts, name: &str, expected: &str) -> bool { fn required_string( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { property(parts, name) .map(ToOwned::to_owned) - .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) } fn required_ip_string( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { required_string(parts, name)? .parse::() .map(|address| address.to_string()) - .map_err(|_| VerboseLoggingExclusionReason::IncompleteNetworkEndpoint) + .map_err(|_| CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint) } fn required_u8( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { property(parts, name) .and_then(parse_u8) - .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) } fn required_u16( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { property(parts, name) .and_then(parse_u16) - .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) } fn required_u32( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { property(parts, name) .and_then(parse_u32) - .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) } fn required_u64( parts: &DecodedEventParts, name: &'static str, -) -> Result { +) -> Result { property(parts, name) .and_then(parse_u64) - .ok_or(VerboseLoggingExclusionReason::EventPayloadMalformed) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) } fn optional_string( @@ -274,7 +491,7 @@ fn optional_string( flags: u32, flag: u32, name: &'static str, -) -> Result, VerboseLoggingExclusionReason> { +) -> Result, CaptureVerboseLoggingOutcomeReason> { if flags & flag == 0 { return Ok(None); } @@ -286,7 +503,7 @@ fn optional_ip_string( flags: u32, flag: u32, name: &'static str, -) -> Result, VerboseLoggingExclusionReason> { +) -> Result, CaptureVerboseLoggingOutcomeReason> { if flags & flag == 0 { return Ok(None); } @@ -298,7 +515,7 @@ fn optional_u8( flags: u32, flag: u32, name: &'static str, -) -> Result, VerboseLoggingExclusionReason> { +) -> Result, CaptureVerboseLoggingOutcomeReason> { if flags & flag == 0 { return Ok(None); } @@ -310,7 +527,7 @@ fn optional_u16( flags: u32, flag: u32, name: &'static str, -) -> Result, VerboseLoggingExclusionReason> { +) -> Result, CaptureVerboseLoggingOutcomeReason> { if flags & flag == 0 { return Ok(None); } @@ -344,6 +561,15 @@ fn parse_u64(value: &str) -> Option { mod tests { use super::*; + type VerboseLoggingExclusionReason = CaptureVerboseLoggingOutcomeReason; + + fn extract_network_denial( + parts: &DecodedEventParts, + ) -> Result { + let analysis = analyze_network_decision(parts); + analysis.denial.ok_or(analysis.reason) + } + fn event(source: u8, reason: u16, fields: &[(&str, &str)]) -> DecodedEventParts { let mut props = vec![ ("SchemaVersion".to_string(), "1".to_string()), @@ -502,25 +728,77 @@ mod tests { #[test] fn intentional_and_proxy_denials_are_verbose_only() { - for (reason, expected) in [ + for (reason, expected, policy_reason, recommendation) in [ ( REASON_TESSERA_EXPLICIT_DENY, VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny, + NetworkDecisionReason::AuthoredExplicitDeny, + ConfigurationRecommendation::ReviewEgressDeny, ), ( REASON_TESSERA_ALLOW_EXCLUSION, VerboseLoggingExclusionReason::IntentionalNetworkPolicyDeny, + NetworkDecisionReason::AllowExclusion, + ConfigurationRecommendation::ReviewAllowExclusion, ), ( REASON_TESSERA_PROXY_CONTAINMENT, VerboseLoggingExclusionReason::ProxyContainment, + NetworkDecisionReason::ProxyContainment, + ConfigurationRecommendation::UseConfiguredProxy, ), ] { let parts = event(SOURCE_TESSERA, reason, &[]); assert_eq!(extract_network_denial(&parts).unwrap_err(), expected); + let analysis = analyze_network_decision(&parts); + assert_eq!(analysis.network_decision_reason, Some(policy_reason)); + assert_eq!(analysis.configuration_recommendation, Some(recommendation)); } } + #[test] + fn direct_default_deny_exposes_only_exact_allow_recommendations() { + let flags = FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; + let mut exact = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("Protocol", "6"), + ("RemoteAddress", "203.0.113.10"), + ("RemotePort", "443"), + ], + ); + replace(&mut exact, "FieldFlags", flags.to_string()); + let analysis = analyze_network_decision(&exact); + assert_eq!( + analysis.network_decision_reason, + Some(NetworkDecisionReason::DirectDefaultDeny) + ); + assert_eq!( + analysis.configuration_recommendation, + Some(ConfigurationRecommendation::AddEgressAllow) + ); + assert_eq!( + analysis.network_endpoint, + Some(NetworkEndpoint { + protocol: "tcp".to_string(), + remote_address: "203.0.113.10".to_string(), + remote_port: Some(443), + }) + ); + + let mut unknown_protocol = exact; + replace(&mut unknown_protocol, "Protocol", "132"); + let analysis = analyze_network_decision(&unknown_protocol); + assert!(analysis.denial.is_some()); + assert_eq!( + analysis.network_decision_reason, + Some(NetworkDecisionReason::DirectDefaultDeny) + ); + assert!(analysis.configuration_recommendation.is_none()); + assert!(analysis.network_endpoint.is_none()); + } + #[test] fn tessera_default_deny_requires_remote_address() { let parts = event(SOURCE_TESSERA, REASON_TESSERA_DIRECT_DEFAULT_DENY, &[]); @@ -651,6 +929,25 @@ mod tests { ); } + #[test] + fn every_original_v1_property_name_is_required() { + for missing in NETWORK_DECISION_V1_FIELDS { + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[("RemoteAddress", "203.0.113.10")], + ); + replace(&mut parts, "FieldFlags", FIELD_REMOTE_ADDRESS.to_string()); + parts.props.retain(|(name, _)| name != missing); + + assert_eq!( + analyze_network_decision(&parts).reason, + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema, + "missing base property {missing} must fail closed" + ); + } + } + #[test] fn unknown_stable_reason_remains_verbose_only() { let parts = event(SOURCE_TESSERA, u16::MAX, &[]); diff --git a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs index 8b92a876b..3f6de1121 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs @@ -1161,7 +1161,9 @@ mod tests { props, }; - let denial = network_extractors::extract_network_denial(&parts).expect("network denial"); + let denial = network_extractors::analyze_network_decision(&parts) + .denial + .expect("network denial"); assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); } diff --git a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs index 05d2ab6d5..35ca57103 100644 --- a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs @@ -7,8 +7,11 @@ use std::io::Read; use std::path::Path; use crate::learning_mode_core::{ - verbose_logging_sibling_path, VerboseLoggingAggregate, VerboseLoggingDocument, - VerboseLoggingProvider, + capture_diagnostics::{ + parse_supported_verbose_document, CaptureVerboseLoggingDocument, + CaptureVerboseLoggingProvider, + }, + verbose_logging_sibling_path, }; use crate::mxc_common::hashing::sha256_hex; use crate::mxc_common::models::{ContainmentBackend, ScriptResponse}; @@ -118,11 +121,8 @@ fn prepare_document(path: &Path) -> Result { return Err("verbose artifact exceeds the telemetry input bound".to_string()); } - let document: VerboseLoggingDocument = serde_json::from_slice(&bytes) - .map_err(|_| "verbose artifact is not valid typed JSON".to_string())?; - if document.version != VerboseLoggingDocument::VERSION { - return Err("verbose artifact uses an unsupported document version".to_string()); - } + let document = parse_supported_verbose_document(&bytes) + .map_err(|_| "verbose artifact is not valid supported typed JSON".to_string())?; let document = project_for_telemetry(document); let compact = serde_json::to_vec(&document) @@ -145,36 +145,44 @@ fn prepare_document(path: &Path) -> Result { }) } -fn project_for_telemetry(mut document: VerboseLoggingDocument) -> VerboseLoggingDocument { +fn project_for_telemetry( + mut document: CaptureVerboseLoggingDocument, +) -> CaptureVerboseLoggingDocument { let mut groups = std::collections::BTreeMap::new(); for mut aggregate in document.signatures { aggregate.signature.provider_guid = canonical_provider_guid(aggregate.signature.provider).to_string(); aggregate.signature.event_name = None; aggregate.signature.properties.clear(); + aggregate.signature.network_endpoint = None; let count = groups.entry(aggregate.signature).or_insert(0u64); *count = count.saturating_add(aggregate.count); } document.signatures = groups .into_iter() - .map(|(signature, count)| VerboseLoggingAggregate { signature, count }) + .map(|(signature, count)| { + crate::learning_mode_core::capture_diagnostics::CaptureVerboseLoggingAggregate { + signature, + count, + } + }) .collect(); document } -fn canonical_provider_guid(provider: VerboseLoggingProvider) -> &'static str { +fn canonical_provider_guid(provider: CaptureVerboseLoggingProvider) -> &'static str { match provider { - VerboseLoggingProvider::KernelGeneral => "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}", - VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + CaptureVerboseLoggingProvider::KernelGeneral => "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}", + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { "{811A1DDB-2E69-5F25-ADC0-4B186170E760}" } - VerboseLoggingProvider::LearningModeNetworkDecision => { + CaptureVerboseLoggingProvider::LearningModeNetworkDecision => { "{71237669-21C3-4101-BD2F-FF38945D725A}" } } } -fn chunk_signatures(document: &VerboseLoggingDocument) -> Result, String> { +fn chunk_signatures(document: &CaptureVerboseLoggingDocument) -> Result, String> { let mut chunks = Vec::new(); let mut current = String::from("["); let mut current_count = 0usize; @@ -221,46 +229,50 @@ fn random_document_id() -> Result { #[cfg(test)] mod tests { use super::*; - use crate::learning_mode_core::{ - VerboseLoggingAggregate, VerboseLoggingDocumentSummary, VerboseLoggingOutcomeReason, - VerboseLoggingProvider, VerboseLoggingSignature, + use crate::learning_mode_core::capture_diagnostics::{ + write_capture_verbose_logging_document, CaptureVerboseLoggingAggregate, + CaptureVerboseLoggingDocumentSummary, CaptureVerboseLoggingOutcomeReason, + CaptureVerboseLoggingSignature, CAPTURE_VERBOSE_LOGGING_VERSION, }; use crate::mxc_common::models::{CaptureDenialsOutput, SandboxOutputMetadata}; fn aggregate_with_reason( event_id: u16, value: &str, - reason: VerboseLoggingOutcomeReason, - ) -> VerboseLoggingAggregate { - VerboseLoggingAggregate { - signature: VerboseLoggingSignature { - event_name: None, - provider: VerboseLoggingProvider::KernelGeneral, + reason: CaptureVerboseLoggingOutcomeReason, + ) -> CaptureVerboseLoggingAggregate { + CaptureVerboseLoggingAggregate { + signature: CaptureVerboseLoggingSignature { + provider: CaptureVerboseLoggingProvider::KernelGeneral, provider_guid: "{a68ca8b7-004f-d7b6-a698-07e2de0f1f5d}".to_string(), event_id, + event_name: None, reason, pid: 42, access_type: None, resource_type: None, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, properties: vec![("Value".to_string(), value.to_string())], }, count: 1, } } - fn aggregate(event_id: u16, value: &str) -> VerboseLoggingAggregate { + fn aggregate(event_id: u16, value: &str) -> CaptureVerboseLoggingAggregate { aggregate_with_reason( event_id, value, - VerboseLoggingOutcomeReason::UnsupportedEventSchema, + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema, ) } - fn document(signatures: Vec) -> VerboseLoggingDocument { - VerboseLoggingDocument { - version: VerboseLoggingDocument::VERSION, + fn document(signatures: Vec) -> CaptureVerboseLoggingDocument { + CaptureVerboseLoggingDocument { + version: CAPTURE_VERBOSE_LOGGING_VERSION, signatures, - summary: VerboseLoggingDocumentSummary { + summary: CaptureVerboseLoggingDocumentSummary { total_occurrences: 1, overflow_occurrences: 0, actionable_overflow_occurrences: 0, @@ -277,7 +289,9 @@ mod tests { let chunks = chunk_signatures(&doc).unwrap(); let rebuilt = chunks .iter() - .flat_map(|chunk| serde_json::from_str::>(chunk).unwrap()) + .flat_map(|chunk| { + serde_json::from_str::>(chunk).unwrap() + }) .collect::>(); assert_eq!(rebuilt, doc.signatures); } @@ -294,7 +308,7 @@ mod tests { let chunks = chunk_signatures(&doc).unwrap(); assert_eq!(chunks.len(), 2); for chunk in chunks { - let parsed: Vec = serde_json::from_str(&chunk).unwrap(); + let parsed: Vec = serde_json::from_str(&chunk).unwrap(); assert_eq!(parsed.len(), 1); } } @@ -308,7 +322,7 @@ mod tests { std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap(); assert!(prepare_document(&path) .unwrap_err() - .contains("unsupported document version")); + .contains("valid supported typed JSON")); } #[test] @@ -321,7 +335,7 @@ mod tests { aggregate(2, "customer-secret-two"), ]); let mut pretty = Vec::new(); - crate::learning_mode_core::write_verbose_logging_document(&mut pretty, &doc).unwrap(); + write_capture_verbose_logging_document(&mut pretty, &doc).unwrap(); std::fs::write(&verbose_path, pretty).unwrap(); let response = ScriptResponse { @@ -342,10 +356,12 @@ mod tests { let signatures = prepared .chunks .iter() - .flat_map(|chunk| serde_json::from_str::>(chunk).unwrap()) + .flat_map(|chunk| { + serde_json::from_str::>(chunk).unwrap() + }) .collect(); let summary = serde_json::from_str(&prepared.summary).unwrap(); - let reconstructed = VerboseLoggingDocument { + let reconstructed = CaptureVerboseLoggingDocument { version: prepared.version, signatures, summary, @@ -370,7 +386,7 @@ mod tests { let mut document = document(vec![aggregate_with_reason( 14, "{A47979D2-C419-11D9-A5B4-001185AD2B89}", - VerboseLoggingOutcomeReason::ComActivation, + CaptureVerboseLoggingOutcomeReason::ComActivation, )]); document.signatures[0].signature.resource_type = Some(crate::learning_mode_core::ResourceType::Other); @@ -378,7 +394,10 @@ mod tests { document = project_for_telemetry(document); let signature = &document.signatures[0].signature; - assert_eq!(signature.reason, VerboseLoggingOutcomeReason::ComActivation); + assert_eq!( + signature.reason, + CaptureVerboseLoggingOutcomeReason::ComActivation + ); assert_eq!( signature.resource_type, Some(crate::learning_mode_core::ResourceType::Other) @@ -393,7 +412,7 @@ mod tests { let path = directory.path().join("denials.verbose.json"); std::fs::write(&path, br#"{"secret":"do-not-send"}"#).unwrap(); let error = prepare_document(&path).unwrap_err(); - assert_eq!(error, "verbose artifact is not valid typed JSON"); + assert_eq!(error, "verbose artifact is not valid supported typed JSON"); assert!(!error.contains("do-not-send")); } @@ -411,7 +430,7 @@ mod tests { assert!(!serialized.contains(injected)); assert_eq!( projected.signatures[0].signature.provider_guid, - canonical_provider_guid(VerboseLoggingProvider::KernelGeneral) + canonical_provider_guid(CaptureVerboseLoggingProvider::KernelGeneral) ); assert!(projected.signatures[0].signature.properties.is_empty()); } @@ -419,7 +438,8 @@ mod tests { #[test] fn telemetry_projection_strips_network_payload_and_canonicalizes_provider() { let mut doc = document(vec![aggregate(1, "private-network-data")]); - doc.signatures[0].signature.provider = VerboseLoggingProvider::LearningModeNetworkDecision; + doc.signatures[0].signature.provider = + CaptureVerboseLoggingProvider::LearningModeNetworkDecision; doc.signatures[0].signature.provider_guid = "untrusted-provider".into(); doc.signatures[0].signature.event_name = Some("NetworkDecisionV1".into()); diff --git a/src/mxc-sdk/tests/learning_mode_public_api_compat.rs b/src/mxc-sdk/tests/learning_mode_public_api_compat.rs new file mode 100644 index 000000000..48e2ad4fd --- /dev/null +++ b/src/mxc-sdk/tests/learning_mode_public_api_compat.rs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use mxc_sdk::learning_mode_core::{VerboseLoggingOutcomeReason, VerboseLoggingProvider}; + +fn provider_name(provider: VerboseLoggingProvider) -> &'static str { + match provider { + VerboseLoggingProvider::KernelGeneral => "kernelGeneral", + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + "privacyAuditingPermissiveLearningMode" + } + } +} + +fn reason_name(reason: VerboseLoggingOutcomeReason) -> &'static str { + match reason { + VerboseLoggingOutcomeReason::Actionable => "actionable", + VerboseLoggingOutcomeReason::UnsupportedEventSchema => "unsupportedEventSchema", + VerboseLoggingOutcomeReason::EventPayloadMalformed => "eventPayloadMalformed", + VerboseLoggingOutcomeReason::DecoderLimitReached => "decoderLimitReached", + VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding => "unsupportedPropertyEncoding", + VerboseLoggingOutcomeReason::MissingObjectType => "missingObjectType", + VerboseLoggingOutcomeReason::MissingObjectName => "missingObjectName", + VerboseLoggingOutcomeReason::UnsupportedObjectType => "unsupportedObjectType", + VerboseLoggingOutcomeReason::UnusableResourcePath => "unusableResourcePath", + VerboseLoggingOutcomeReason::UnresolvedCapability => "unresolvedCapability", + VerboseLoggingOutcomeReason::ComActivation => "comActivation", + VerboseLoggingOutcomeReason::ComInterfaceCall => "comInterfaceCall", + VerboseLoggingOutcomeReason::NotActionable => "notActionable", + } +} + +#[test] +fn existing_verbose_enums_remain_exhaustively_matchable() { + assert_eq!( + provider_name(VerboseLoggingProvider::KernelGeneral), + "kernelGeneral" + ); + assert_eq!( + reason_name(VerboseLoggingOutcomeReason::ComInterfaceCall), + "comInterfaceCall" + ); +} From cfbf65bf2c5ff7f6924c96c4effcd773786eea59 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:19:01 -0700 Subject: [PATCH 08/13] fix: redact WFP endpoints from telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs index 35ca57103..462e02c2f 100644 --- a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs @@ -232,7 +232,7 @@ mod tests { use crate::learning_mode_core::capture_diagnostics::{ write_capture_verbose_logging_document, CaptureVerboseLoggingAggregate, CaptureVerboseLoggingDocumentSummary, CaptureVerboseLoggingOutcomeReason, - CaptureVerboseLoggingSignature, CAPTURE_VERBOSE_LOGGING_VERSION, + CaptureVerboseLoggingSignature, NetworkEndpoint, CAPTURE_VERBOSE_LOGGING_VERSION, }; use crate::mxc_common::models::{CaptureDenialsOutput, SandboxOutputMetadata}; @@ -423,6 +423,11 @@ mod tests { doc.signatures[0].signature.provider_guid = injected.to_string(); doc.signatures[0].signature.event_name = Some(injected.to_string()); doc.signatures[0].signature.properties = vec![(injected.to_string(), injected.to_string())]; + doc.signatures[0].signature.network_endpoint = Some(NetworkEndpoint { + protocol: "tcp".to_string(), + remote_address: injected.to_string(), + remote_port: Some(443), + }); let projected = project_for_telemetry(doc); let serialized = serde_json::to_string(&projected).unwrap(); @@ -433,6 +438,7 @@ mod tests { canonical_provider_guid(CaptureVerboseLoggingProvider::KernelGeneral) ); assert!(projected.signatures[0].signature.properties.is_empty()); + assert!(projected.signatures[0].signature.network_endpoint.is_none()); } #[test] From 34b48ce85e86600cf9cbd098534bf80d6dab3d53 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 10:51:49 -0700 Subject: [PATCH 09/13] fix: close WFP diagnostic contracts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../learning_mode_core/capture_diagnostics.rs | 442 +++++++++++++++++- .../core/learning_mode_windows/etl_decode.rs | 40 ++ .../network_extractors.rs | 62 ++- .../src/core/mxc_engine/verbose_telemetry.rs | 54 +-- 4 files changed, 544 insertions(+), 54 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs index 2849ee7be..c04eb49a0 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs @@ -19,7 +19,6 @@ pub(crate) const CAPTURE_VERBOSE_LOGGING_VERSION: u32 = 5; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) enum CaptureVerboseLoggingProvider { - Other, KernelGeneral, PrivacyAuditingPermissiveLearningMode, LearningModeNetworkDecision, @@ -75,7 +74,7 @@ pub(crate) enum ConfigurationRecommendation { } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct NetworkEndpoint { pub(crate) protocol: String, pub(crate) remote_address: String, @@ -84,7 +83,7 @@ pub(crate) struct NetworkEndpoint { } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct CaptureVerboseLoggingSignature { pub(crate) provider: CaptureVerboseLoggingProvider, pub(crate) provider_guid: String, @@ -107,7 +106,7 @@ pub(crate) struct CaptureVerboseLoggingSignature { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct CaptureVerboseLoggingAggregate { pub(crate) signature: CaptureVerboseLoggingSignature, pub(crate) count: u64, @@ -140,14 +139,25 @@ impl CaptureVerboseLoggingSummary { return; } + let actionable = signature.reason.is_actionable(); + let can_evict = actionable + && self + .signatures + .iter() + .any(|group| !group.signature.reason.is_actionable()); + if (self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || *retained_bytes >= max_bytes) + && !can_evict + { + self.record_overflow(actionable, 1); + return; + } + let serialized_len = serialized_signature_len(&signature); while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || retained_bytes.saturating_add(serialized_len) > max_bytes { - if !signature.reason.is_actionable() - || !self.evict_one_nonactionable_group(retained_bytes) - { - self.record_overflow(signature.reason.is_actionable(), 1); + if !actionable || !self.evict_one_nonactionable_group(retained_bytes) { + self.record_overflow(actionable, 1); return; } } @@ -221,7 +231,7 @@ impl CaptureVerboseLoggingSummary { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct CaptureVerboseLoggingDocument { pub(crate) version: u32, pub(crate) signatures: Vec, @@ -229,7 +239,7 @@ pub(crate) struct CaptureVerboseLoggingDocument { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct CaptureVerboseLoggingDocumentSummary { pub(crate) total_occurrences: u64, pub(crate) overflow_occurrences: u64, @@ -358,13 +368,262 @@ pub(crate) fn write_capture_verbose_logging_document( pub(crate) fn parse_supported_verbose_document( bytes: &[u8], ) -> Result { - let document: CaptureVerboseLoggingDocument = serde_json::from_slice(bytes)?; - if matches!(document.version, 3 | 4 | CAPTURE_VERBOSE_LOGGING_VERSION) { - Ok(document) - } else { - Err(::custom( + let envelope: VerboseDocumentVersion = serde_json::from_slice(bytes)?; + match envelope.version { + 3 => serde_json::from_slice::>(bytes) + .map(normalize_version3_document), + 4 => serde_json::from_slice::>(bytes) + .map(normalize_version4_document), + CAPTURE_VERBOSE_LOGGING_VERSION => serde_json::from_slice(bytes), + _ => Err(::custom( "unsupported verbose logging document version", - )) + )), + } +} + +#[derive(Deserialize)] +struct VerboseDocumentVersion { + version: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VersionedVerboseDocument { + version: u32, + signatures: Vec>, + summary: CaptureVerboseLoggingDocumentSummary, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VersionedVerboseAggregate { + signature: S, + count: u64, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Version3Provider { + KernelGeneral, + PrivacyAuditingPermissiveLearningMode, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Version4Provider { + KernelGeneral, + PrivacyAuditingPermissiveLearningMode, + LearningModeNetworkDecision, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Version3Reason { + Actionable, + UnsupportedEventSchema, + EventPayloadMalformed, + DecoderLimitReached, + UnsupportedPropertyEncoding, + MissingObjectType, + MissingObjectName, + UnsupportedObjectType, + UnusableResourcePath, + UnresolvedCapability, + ComActivation, + ComInterfaceCall, + NotActionable, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Version4Reason { + Actionable, + UnsupportedEventSchema, + SchemaUnavailable, + EventPayloadMalformed, + DecoderLimitReached, + UnsupportedPropertyEncoding, + MissingObjectType, + MissingObjectName, + UnsupportedObjectType, + UnusableResourcePath, + UnresolvedCapability, + ComActivation, + ComInterfaceCall, + NotActionable, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Version3Signature { + provider: Version3Provider, + provider_guid: String, + event_id: u16, + reason: Version3Reason, + pid: u32, + #[serde(default)] + access_type: Option, + #[serde(default)] + resource_type: Option, + properties: Vec<(String, String)>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Version4Signature { + provider: Version4Provider, + provider_guid: String, + event_id: u16, + #[serde(default)] + event_name: Option, + reason: Version4Reason, + pid: u32, + #[serde(default)] + access_type: Option, + #[serde(default)] + resource_type: Option, + properties: Vec<(String, String)>, +} + +fn normalize_version3_document( + document: VersionedVerboseDocument, +) -> CaptureVerboseLoggingDocument { + CaptureVerboseLoggingDocument { + version: document.version, + signatures: document + .signatures + .into_iter() + .map(|aggregate| CaptureVerboseLoggingAggregate { + signature: CaptureVerboseLoggingSignature { + provider: match aggregate.signature.provider { + Version3Provider::KernelGeneral => { + CaptureVerboseLoggingProvider::KernelGeneral + } + Version3Provider::PrivacyAuditingPermissiveLearningMode => { + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + } + }, + provider_guid: aggregate.signature.provider_guid, + event_id: aggregate.signature.event_id, + event_name: None, + reason: normalize_version3_reason(aggregate.signature.reason), + pid: aggregate.signature.pid, + access_type: aggregate.signature.access_type, + resource_type: aggregate.signature.resource_type, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + properties: aggregate.signature.properties, + }, + count: aggregate.count, + }) + .collect(), + summary: document.summary, + } +} + +fn normalize_version4_document( + document: VersionedVerboseDocument, +) -> CaptureVerboseLoggingDocument { + CaptureVerboseLoggingDocument { + version: document.version, + signatures: document + .signatures + .into_iter() + .map(|aggregate| CaptureVerboseLoggingAggregate { + signature: CaptureVerboseLoggingSignature { + provider: match aggregate.signature.provider { + Version4Provider::KernelGeneral => { + CaptureVerboseLoggingProvider::KernelGeneral + } + Version4Provider::PrivacyAuditingPermissiveLearningMode => { + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + } + Version4Provider::LearningModeNetworkDecision => { + CaptureVerboseLoggingProvider::LearningModeNetworkDecision + } + }, + provider_guid: aggregate.signature.provider_guid, + event_id: aggregate.signature.event_id, + event_name: aggregate.signature.event_name, + reason: normalize_version4_reason(aggregate.signature.reason), + pid: aggregate.signature.pid, + access_type: aggregate.signature.access_type, + resource_type: aggregate.signature.resource_type, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + properties: aggregate.signature.properties, + }, + count: aggregate.count, + }) + .collect(), + summary: document.summary, + } +} + +fn normalize_version3_reason(reason: Version3Reason) -> CaptureVerboseLoggingOutcomeReason { + match reason { + Version3Reason::Actionable => CaptureVerboseLoggingOutcomeReason::Actionable, + Version3Reason::UnsupportedEventSchema => { + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema + } + Version3Reason::EventPayloadMalformed => { + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed + } + Version3Reason::DecoderLimitReached => { + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached + } + Version3Reason::UnsupportedPropertyEncoding => { + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + Version3Reason::MissingObjectType => CaptureVerboseLoggingOutcomeReason::MissingObjectType, + Version3Reason::MissingObjectName => CaptureVerboseLoggingOutcomeReason::MissingObjectName, + Version3Reason::UnsupportedObjectType => { + CaptureVerboseLoggingOutcomeReason::UnsupportedObjectType + } + Version3Reason::UnusableResourcePath => { + CaptureVerboseLoggingOutcomeReason::UnusableResourcePath + } + Version3Reason::UnresolvedCapability => { + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability + } + Version3Reason::ComActivation => CaptureVerboseLoggingOutcomeReason::ComActivation, + Version3Reason::ComInterfaceCall => CaptureVerboseLoggingOutcomeReason::ComInterfaceCall, + Version3Reason::NotActionable => CaptureVerboseLoggingOutcomeReason::NotActionable, + } +} + +fn normalize_version4_reason(reason: Version4Reason) -> CaptureVerboseLoggingOutcomeReason { + match reason { + Version4Reason::SchemaUnavailable => CaptureVerboseLoggingOutcomeReason::SchemaUnavailable, + Version4Reason::Actionable => CaptureVerboseLoggingOutcomeReason::Actionable, + Version4Reason::UnsupportedEventSchema => { + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema + } + Version4Reason::EventPayloadMalformed => { + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed + } + Version4Reason::DecoderLimitReached => { + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached + } + Version4Reason::UnsupportedPropertyEncoding => { + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + Version4Reason::MissingObjectType => CaptureVerboseLoggingOutcomeReason::MissingObjectType, + Version4Reason::MissingObjectName => CaptureVerboseLoggingOutcomeReason::MissingObjectName, + Version4Reason::UnsupportedObjectType => { + CaptureVerboseLoggingOutcomeReason::UnsupportedObjectType + } + Version4Reason::UnusableResourcePath => { + CaptureVerboseLoggingOutcomeReason::UnusableResourcePath + } + Version4Reason::UnresolvedCapability => { + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability + } + Version4Reason::ComActivation => CaptureVerboseLoggingOutcomeReason::ComActivation, + Version4Reason::ComInterfaceCall => CaptureVerboseLoggingOutcomeReason::ComInterfaceCall, + Version4Reason::NotActionable => CaptureVerboseLoggingOutcomeReason::NotActionable, } } @@ -578,4 +837,155 @@ mod tests { CAPTURE_VERBOSE_LOGGING_VERSION ); } + + #[test] + fn parser_accepts_the_exact_version_four_vocabulary() { + let document = serde_json::json!({ + "version": 4, + "signatures": [{ + "signature": { + "provider": "learningModeNetworkDecision", + "providerGuid": "{71237669-21C3-4101-BD2F-FF38945D725A}", + "eventId": 1, + "eventName": "NetworkDecisionV1", + "reason": "schemaUnavailable", + "pid": 0, + "properties": [] + }, + "count": 1 + }], + "summary": { + "totalOccurrences": 1, + "overflowOccurrences": 0, + "actionableOverflowOccurrences": 0, + "aggregateGroupsTruncated": false, + "processedEventsTruncated": false, + "actionableLimitReached": false + } + }); + + let parsed = parse_supported_verbose_document(&serde_json::to_vec(&document).unwrap()) + .expect("version four document should parse"); + + assert_eq!(parsed.version, 4); + assert_eq!( + parsed.signatures[0].signature.provider, + CaptureVerboseLoggingProvider::LearningModeNetworkDecision + ); + assert_eq!( + parsed.signatures[0].signature.reason, + CaptureVerboseLoggingOutcomeReason::SchemaUnavailable + ); + assert_eq!( + parsed.signatures[0].signature.event_name.as_deref(), + Some("NetworkDecisionV1") + ); + } + + #[test] + fn parser_rejects_cross_version_and_unknown_vocabulary() { + let summary = serde_json::json!({ + "totalOccurrences": 1, + "overflowOccurrences": 0, + "actionableOverflowOccurrences": 0, + "aggregateGroupsTruncated": false, + "processedEventsTruncated": false, + "actionableLimitReached": false + }); + let base_signature = serde_json::json!({ + "provider": "kernelGeneral", + "providerGuid": "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}", + "eventId": 14, + "reason": "actionable", + "pid": 42, + "properties": [] + }); + let document = |version, signature: serde_json::Value| { + serde_json::json!({ + "version": version, + "signatures": [{"signature": signature, "count": 1}], + "summary": summary + }) + }; + + let mut version3_event_name = base_signature.clone(); + version3_event_name["eventName"] = serde_json::json!("AccessCheck"); + assert!(parse_supported_verbose_document( + &serde_json::to_vec(&document(3, version3_event_name)).unwrap() + ) + .is_err()); + + let mut version3_provider = base_signature.clone(); + version3_provider["provider"] = serde_json::json!("learningModeNetworkDecision"); + assert!(parse_supported_verbose_document( + &serde_json::to_vec(&document(3, version3_provider)).unwrap() + ) + .is_err()); + + let mut version4_network_field = base_signature.clone(); + version4_network_field["networkDecisionReason"] = serde_json::json!("directDefaultDeny"); + assert!(parse_supported_verbose_document( + &serde_json::to_vec(&document(4, version4_network_field)).unwrap() + ) + .is_err()); + + let mut version4_reason = base_signature.clone(); + version4_reason["reason"] = serde_json::json!("proxyContainment"); + assert!(parse_supported_verbose_document( + &serde_json::to_vec(&document(4, version4_reason)).unwrap() + ) + .is_err()); + + let mut version5_unknown = + network_signature(CaptureVerboseLoggingOutcomeReason::Actionable); + version5_unknown.properties.clear(); + let mut version5 = serde_json::to_value(CaptureVerboseLoggingDocument { + version: CAPTURE_VERBOSE_LOGGING_VERSION, + signatures: vec![CaptureVerboseLoggingAggregate { + signature: version5_unknown, + count: 1, + }], + summary: CaptureVerboseLoggingDocumentSummary { + total_occurrences: 1, + overflow_occurrences: 0, + actionable_overflow_occurrences: 0, + aggregate_groups_truncated: false, + processed_events_truncated: false, + actionable_limit_reached: false, + }, + }) + .unwrap(); + version5["signatures"][0]["signature"]["futureField"] = serde_json::json!(true); + assert!(parse_supported_verbose_document(&serde_json::to_vec(&version5).unwrap()).is_err()); + } + + #[test] + fn saturated_nonactionable_summary_moves_new_groups_directly_to_overflow() { + let mut summary = CaptureVerboseLoggingSummary::default(); + let mut retained_bytes = 0; + for event_id in 0..MAX_VERBOSE_LOGGING_GROUPS { + let mut signature = + network_signature(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason); + signature.event_id = u16::try_from(event_id).unwrap(); + summary.record_with_byte_budget( + signature, + &mut retained_bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + } + let retained_bytes_before = retained_bytes; + let mut overflow = + network_signature(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason); + overflow.event_id = u16::MAX; + + summary.record_with_byte_budget( + overflow, + &mut retained_bytes, + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + ); + + assert_eq!(summary.signatures.len(), MAX_VERBOSE_LOGGING_GROUPS); + assert_eq!(summary.overflow_occurrences, 1); + assert_eq!(retained_bytes, retained_bytes_before); + } } diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 0e22544ca..86c3419b0 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -3950,4 +3950,44 @@ mod tests { assert_eq!(group.count, 1); } } + + #[test] + fn network_diagnostics_preserve_observed_event_identity() { + let mut unsupported = Accumulator::analyze(); + unsupported.record_network_outcome( + 999, + None, + &crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis { + denial: None, + reason: CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + classification: (None, None), + properties: Vec::new(), + }, + ); + let signature = &unsupported.network_verbose_logging.signatures[0].signature; + assert_eq!(signature.event_id, 999); + assert!(signature.event_name.is_none()); + + let mut malformed = Accumulator::analyze(); + malformed.record_event_decode_error( + crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER, + 77, + 0, + tdh_decode::DecodeError::event( + tdh_decode::EventDecodeKind::PayloadMalformed, + "malformed future event".to_string(), + Some("FutureNetworkDecision".to_string()), + ), + ); + let signature = &malformed.network_verbose_logging.signatures[0].signature; + assert_eq!(signature.event_id, 77); + assert_eq!( + signature.event_name.as_deref(), + Some("FutureNetworkDecision") + ); + assert!(signature.properties.is_empty()); + } } diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 7aeef37b3..9f3a56280 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -273,7 +273,9 @@ fn extract_tessera( return Ok(network_diagnostic( reason, network_decision_reason, - recommendation, + (network_decision_reason != NetworkDecisionReason::DirectDefaultDeny) + .then_some(recommendation) + .flatten(), None, ResourceType::Network, )); @@ -386,10 +388,16 @@ fn exact_network_endpoint( let remote_address = required_ip_string(parts, "RemoteAddress")?; let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; - let protocol = match protocol { - Some(6) => "tcp", - Some(17) => "udp", - Some(1 | 58) => "icmp", + let (protocol, remote_port) = match protocol { + Some(6) => match remote_port { + Some(1..=u16::MAX) => ("tcp", remote_port), + _ => return Ok(None), + }, + Some(17) => match remote_port { + Some(1..=u16::MAX) => ("udp", remote_port), + _ => return Ok(None), + }, + Some(1 | 58) => ("icmp", None), _ => return Ok(None), }; Ok(Some(NetworkEndpoint { @@ -787,7 +795,7 @@ mod tests { }) ); - let mut unknown_protocol = exact; + let mut unknown_protocol = exact.clone(); replace(&mut unknown_protocol, "Protocol", "132"); let analysis = analyze_network_decision(&unknown_protocol); assert!(analysis.denial.is_some()); @@ -797,6 +805,48 @@ mod tests { ); assert!(analysis.configuration_recommendation.is_none()); assert!(analysis.network_endpoint.is_none()); + + let mut zero_port = exact.clone(); + replace(&mut zero_port, "RemotePort", "0"); + let analysis = analyze_network_decision(&zero_port); + assert!(analysis.denial.is_some()); + assert!(analysis.configuration_recommendation.is_none()); + assert!(analysis.network_endpoint.is_none()); + + let mut missing_port = exact.clone(); + replace( + &mut missing_port, + "FieldFlags", + (FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS).to_string(), + ); + let analysis = analyze_network_decision(&missing_port); + assert!(analysis.denial.is_some()); + assert!(analysis.configuration_recommendation.is_none()); + assert!(analysis.network_endpoint.is_none()); + + let mut icmp_with_port = exact.clone(); + replace(&mut icmp_with_port, "Protocol", "1"); + replace(&mut icmp_with_port, "RemotePort", "8"); + let analysis = analyze_network_decision(&icmp_with_port); + assert_eq!( + analysis.configuration_recommendation, + Some(ConfigurationRecommendation::AddEgressAllow) + ); + assert_eq!( + analysis.network_endpoint, + Some(NetworkEndpoint { + protocol: "icmp".to_string(), + remote_address: "203.0.113.10".to_string(), + remote_port: None, + }) + ); + + let mut malformed_port = exact; + replace(&mut malformed_port, "RemotePort", "not-a-port"); + let analysis = analyze_network_decision(&malformed_port); + assert!(analysis.denial.is_none()); + assert!(analysis.configuration_recommendation.is_none()); + assert!(analysis.network_endpoint.is_none()); } #[test] diff --git a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs index 462e02c2f..f20bfcf61 100644 --- a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs @@ -371,10 +371,12 @@ mod tests { assert_eq!(compact.len() as u64, prepared.document_bytes); assert_eq!(sha256_hex(&compact), prepared.document_sha256); assert_eq!(reconstructed, project_for_telemetry(doc)); - assert!(reconstructed - .signatures - .iter() - .all(|aggregate| aggregate.signature.properties.is_empty())); + assert!(reconstructed.signatures.iter().all(|aggregate| aggregate + .signature + .properties + .is_empty() + && aggregate.signature.event_name.is_none() + && aggregate.signature.network_endpoint.is_none())); assert!(reconstructed.signatures.iter().all(|aggregate| { aggregate.signature.provider_guid == canonical_provider_guid(aggregate.signature.provider) @@ -437,41 +439,21 @@ mod tests { projected.signatures[0].signature.provider_guid, canonical_provider_guid(CaptureVerboseLoggingProvider::KernelGeneral) ); + assert!(projected.signatures[0].signature.event_name.is_none()); assert!(projected.signatures[0].signature.properties.is_empty()); assert!(projected.signatures[0].signature.network_endpoint.is_none()); } #[test] - fn telemetry_projection_strips_network_payload_and_canonicalizes_provider() { - let mut doc = document(vec![aggregate(1, "private-network-data")]); - doc.signatures[0].signature.provider = - CaptureVerboseLoggingProvider::LearningModeNetworkDecision; - doc.signatures[0].signature.provider_guid = "untrusted-provider".into(); - doc.signatures[0].signature.event_name = Some("NetworkDecisionV1".into()); - - let projected = project_for_telemetry(doc); - - assert_eq!( - projected.signatures[0].signature.provider_guid, - "{71237669-21C3-4101-BD2F-FF38945D725A}" - ); - assert!(projected.signatures[0].signature.properties.is_empty()); - assert!(projected.signatures[0].signature.event_name.is_none()); - let json = serde_json::to_string(&projected).unwrap(); - assert!(!json.contains("private-network-data")); - assert!(!json.contains("untrusted-provider")); - } - - #[test] - fn telemetry_deduplicates_after_removing_unknown_provider_guids_and_properties() { + fn telemetry_projection_regroups_after_removing_sensitive_fields() { let mut first = aggregate(999, "first-secret"); - first.signature.provider_guid = "first-provider".into(); - first.signature.event_name = Some("first-event".into()); + first.signature.provider_guid = "first-provider".to_string(); + first.signature.event_name = Some("first-event".to_string()); first.count = 3; let mut second = first.clone(); - second.signature.provider_guid = "second-provider".into(); - second.signature.event_name = Some("second-event".into()); - second.signature.properties[0].1 = "second-secret".into(); + second.signature.provider_guid = "second-provider".to_string(); + second.signature.event_name = Some("second-event".to_string()); + second.signature.properties[0].1 = "second-secret".to_string(); second.count = 4; let mut input = document(vec![first, second]); input.summary.total_occurrences = 7; @@ -482,10 +464,18 @@ mod tests { assert_eq!(projected.signatures[0].count, 7); assert_eq!( projected.signatures[0].signature.provider_guid, - "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}" + canonical_provider_guid(CaptureVerboseLoggingProvider::KernelGeneral) ); assert!(projected.signatures[0].signature.event_name.is_none()); assert!(projected.signatures[0].signature.properties.is_empty()); assert_eq!(projected.summary.total_occurrences, 7); } + + #[test] + fn network_provider_uses_the_canonical_guid() { + assert_eq!( + canonical_provider_guid(CaptureVerboseLoggingProvider::LearningModeNetworkDecision), + "{71237669-21C3-4101-BD2F-FF38945D725A}" + ); + } } From c4bcc4bf0d28a0c420bb91c8f3e1137cbe479768 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 11:05:20 -0700 Subject: [PATCH 10/13] fix: preserve WFP diagnostic capacity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../core/learning_mode_windows/etl_decode.rs | 66 ++++++++++++++++++- .../network_extractors.rs | 34 ++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 86c3419b0..c5eb322b6 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -35,7 +35,7 @@ use crate::learning_mode_core::{ }, AnalysisResult, AnalyzeError, DenialAnalyzer, DeniedResource, ProcessLifetime, VerboseLoggingOutcomeReason, VerboseLoggingProvider, VerboseLoggingSignature, - VerboseLoggingSummary, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + VerboseLoggingSummary, MAX_VERBOSE_LOGGING_GROUPS, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, }; use windows::core::PWSTR; use windows::Win32::System::Diagnostics::Etw::{ @@ -431,6 +431,8 @@ impl<'visitor> Accumulator<'visitor> { ); if reason.is_actionable() && self.verbose_logging.overflow_occurrences > overflow_before + && (self.verbose_logging.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS + || self.verbose_logging_signature_bytes >= MAX_VERBOSE_LOGGING_SIGNATURE_BYTES) && self .verbose_logging .signatures @@ -476,6 +478,9 @@ impl<'visitor> Accumulator<'visitor> { ); if analysis.reason.is_actionable() && self.network_verbose_logging.overflow_occurrences > overflow_before + && (self.network_verbose_logging.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS + || self.network_verbose_logging_signature_bytes + >= MAX_VERBOSE_LOGGING_SIGNATURE_BYTES) && self .network_verbose_logging .signatures @@ -3707,6 +3712,65 @@ mod tests { assert!(accumulator.verbose_logging_signature_bytes <= MAX_VERBOSE_LOGGING_SIGNATURE_BYTES); } + #[test] + fn oversized_actionable_signature_does_not_saturate_remaining_byte_budget() { + let remaining_bytes = 512; + let mut accumulator = Accumulator::analyze(); + accumulator.verbose_logging_signature_bytes = + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES - remaining_bytes; + accumulator.record_exclusion( + VerboseLoggingProvider::KernelGeneral, + 14, + VerboseLoggingOutcomeReason::Actionable, + 1, + vec![("Large".to_string(), "x".repeat(remaining_bytes))], + ); + + assert_eq!(accumulator.verbose_logging.overflow_occurrences, 1); + assert!(!accumulator.verbose_logging_actionable_only_saturated); + + accumulator.record_exclusion( + VerboseLoggingProvider::KernelGeneral, + 15, + VerboseLoggingOutcomeReason::Actionable, + 1, + Vec::new(), + ); + + assert_eq!(accumulator.verbose_logging.signatures.len(), 1); + assert_eq!(accumulator.verbose_logging.overflow_occurrences, 1); + } + + #[test] + fn oversized_network_signature_does_not_saturate_remaining_byte_budget() { + let remaining_bytes = 512; + let mut accumulator = Accumulator::analyze(); + accumulator.network_verbose_logging_signature_bytes = + MAX_VERBOSE_LOGGING_SIGNATURE_BYTES - remaining_bytes; + let analysis = crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis { + denial: None, + reason: CaptureVerboseLoggingOutcomeReason::Actionable, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + classification: (None, None), + properties: vec![("Large".to_string(), "x".repeat(remaining_bytes))], + }; + accumulator.record_network_outcome(1, Some("NetworkDecisionV1".to_string()), &analysis); + + assert_eq!(accumulator.network_verbose_logging.overflow_occurrences, 1); + assert!(!accumulator.network_verbose_logging_actionable_only_saturated); + + let analysis = crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis { + properties: Vec::new(), + ..analysis + }; + accumulator.record_network_outcome(2, None, &analysis); + + assert_eq!(accumulator.network_verbose_logging.signatures.len(), 1); + assert_eq!(accumulator.network_verbose_logging.overflow_occurrences, 1); + } + #[test] #[test] fn unknown_resource_signature_redacts_the_entire_file_path() { let events = vec![kernel_event( diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 9f3a56280..e42254ac8 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -421,6 +421,10 @@ fn format_network_resource(protocol: Option, address: &str, port: Option None, + _ => port, + }; port.map_or_else( || format!("{scheme}://{host}"), |port| format!("{scheme}://{host}:{port}"), @@ -734,6 +738,36 @@ mod tests { ); } + #[test] + fn tessera_icmp_resources_ignore_irrelevant_ports() { + for (protocol, expected) in [(1, "icmp://203.0.113.10"), (58, "icmpv6://[2001:db8::1]")] { + let address = if protocol == 1 { + "203.0.113.10" + } else { + "2001:db8::1" + }; + let flags = FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("Protocol", &protocol.to_string()), + ("RemoteAddress", address), + ("RemotePort", "8"), + ], + ); + replace(&mut parts, "FieldFlags", flags.to_string()); + + let analysis = analyze_network_decision(&parts); + assert_eq!(analysis.denial.unwrap().object_name, expected); + assert_eq!( + analysis.network_endpoint.unwrap().remote_port, + None, + "ICMP recommendations must not carry ports" + ); + } + } + #[test] fn intentional_and_proxy_denials_are_verbose_only() { for (reason, expected, policy_reason, recommendation) in [ From eb7cb2db943f26625031f699b89db1bc7462484a Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 12:13:15 -0700 Subject: [PATCH 11/13] fix: reconcile WFP diagnostics with verbose v4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../common/capture_output.rs | 1 - .../src/core/learning_mode_core/analyze.rs | 4 - .../learning_mode_core/capture_diagnostics.rs | 190 ++++++++-- .../learning_mode_core/verbose_logging.rs | 83 ++--- .../core/learning_mode_windows/etl_decode.rs | 332 ++++++++++++------ .../core/learning_mode_windows/etl_filter.rs | 15 +- .../network_extractors.rs | 1 + .../core/learning_mode_windows/tdh_decode.rs | 1 + 8 files changed, 431 insertions(+), 196 deletions(-) diff --git a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs index 909dab7c0..5e5be87d3 100644 --- a/src/mxc-sdk/src/backends/process_container/common/capture_output.rs +++ b/src/mxc-sdk/src/backends/process_container/common/capture_output.rs @@ -336,7 +336,6 @@ mod tests { let output_path = directory.path().join("denials.json"); let mut analysis = AnalysisResult::complete(Vec::new()); analysis.verbose_logging.record(VerboseLoggingSignature { - event_name: None, provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}".to_string(), event_id: 14, diff --git a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs index 47a9a04cc..3747e63c1 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/analyze.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/analyze.rs @@ -306,7 +306,6 @@ mod tests { let signature = |pid| VerboseLoggingAggregate { signature: VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, - event_name: None, provider_guid: "provider".to_string(), event_id: 14, reason: VerboseLoggingOutcomeReason::Actionable, @@ -352,7 +351,6 @@ mod tests { signatures: vec![VerboseLoggingAggregate { signature: VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, - event_name: None, provider_guid: "provider".to_string(), event_id: 14, reason: VerboseLoggingOutcomeReason::Actionable, @@ -397,7 +395,6 @@ mod tests { signature: VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "provider".to_string(), - event_name: None, event_id: 14, reason: VerboseLoggingOutcomeReason::Actionable, pid: 1, @@ -432,7 +429,6 @@ mod tests { .map(|pid| VerboseLoggingAggregate { signature: VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, - event_name: None, provider_guid: "provider".to_string(), event_id: 14, reason: VerboseLoggingOutcomeReason::MissingObjectName, diff --git a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs index c04eb49a0..f72862017 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use super::{ AccessType, AnalysisResult, ResourceType, VerboseLoggingAggregate, VerboseLoggingOutcomeReason, - VerboseLoggingProvider, VerboseLoggingSummary, MAX_VERBOSE_LOGGING_GROUPS, - MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, + VerboseLoggingProvider, VerboseLoggingSignature, VerboseLoggingSummary, + MAX_VERBOSE_LOGGING_GROUPS, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, }; /// Verbose document version owned by the WFP diagnostic extension. @@ -153,6 +153,10 @@ impl CaptureVerboseLoggingSummary { } let serialized_len = serialized_signature_len(&signature); + if serialized_len > max_bytes { + self.record_overflow(actionable, 1); + return; + } while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || retained_bytes.saturating_add(serialized_len) > max_bytes { @@ -194,15 +198,6 @@ impl CaptureVerboseLoggingSummary { } } - pub(crate) fn actionable_occurrences(&self) -> u64 { - self.signatures - .iter() - .filter(|aggregate| aggregate.signature.reason.is_actionable()) - .fold(self.actionable_overflow_occurrences, |total, aggregate| { - total.saturating_add(aggregate.count) - }) - } - fn record_overflow(&mut self, actionable: bool, count: u64) { self.total_occurrences = self.total_occurrences.saturating_add(count); self.overflow_occurrences = self.overflow_occurrences.saturating_add(count); @@ -322,26 +317,45 @@ pub(crate) struct CaptureAnalysis { impl CaptureAnalysis { pub(crate) fn into_legacy(mut self) -> AnalysisResult { - let omitted = self.network_verbose_logging.total_occurrences; - if omitted > 0 { - self.verbose_logging.total_occurrences = self - .verbose_logging - .total_occurrences - .saturating_add(omitted); - self.verbose_logging.overflow_occurrences = self - .verbose_logging - .overflow_occurrences - .saturating_add(omitted); - self.verbose_logging.actionable_overflow_occurrences = self - .verbose_logging - .actionable_overflow_occurrences - .saturating_add(self.network_verbose_logging.actionable_occurrences()); - self.verbose_logging.aggregate_groups_truncated = true; - self.verbose_logging.processed_events_truncated |= - self.network_verbose_logging.processed_events_truncated; - self.verbose_logging.actionable_limit_reached |= - self.network_verbose_logging.actionable_limit_reached; + for aggregate in self.network_verbose_logging.signatures { + if let Some(signature) = Self::capture_signature_to_legacy(&aggregate.signature) { + Self::merge_legacy_aggregate(&mut self.verbose_logging, signature, aggregate.count); + } else { + self.verbose_logging.total_occurrences = self + .verbose_logging + .total_occurrences + .saturating_add(aggregate.count); + self.verbose_logging.overflow_occurrences = self + .verbose_logging + .overflow_occurrences + .saturating_add(aggregate.count); + if aggregate.signature.reason.is_actionable() { + self.verbose_logging.actionable_overflow_occurrences = self + .verbose_logging + .actionable_overflow_occurrences + .saturating_add(aggregate.count); + } + self.verbose_logging.aggregate_groups_truncated = true; + } } + self.verbose_logging.total_occurrences = self + .verbose_logging + .total_occurrences + .saturating_add(self.network_verbose_logging.overflow_occurrences); + self.verbose_logging.overflow_occurrences = self + .verbose_logging + .overflow_occurrences + .saturating_add(self.network_verbose_logging.overflow_occurrences); + self.verbose_logging.actionable_overflow_occurrences = self + .verbose_logging + .actionable_overflow_occurrences + .saturating_add(self.network_verbose_logging.actionable_overflow_occurrences); + self.verbose_logging.aggregate_groups_truncated |= + self.network_verbose_logging.aggregate_groups_truncated; + self.verbose_logging.processed_events_truncated |= + self.network_verbose_logging.processed_events_truncated; + self.verbose_logging.actionable_limit_reached |= + self.network_verbose_logging.actionable_limit_reached; AnalysisResult { denials: self.denials, denied_resources_truncated: self.denied_resources_truncated, @@ -349,6 +363,103 @@ impl CaptureAnalysis { } } + fn capture_signature_to_legacy( + signature: &CaptureVerboseLoggingSignature, + ) -> Option { + let provider = match signature.provider { + CaptureVerboseLoggingProvider::KernelGeneral => VerboseLoggingProvider::KernelGeneral, + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + } + CaptureVerboseLoggingProvider::LearningModeNetworkDecision => return None, + }; + let reason = match signature.reason { + CaptureVerboseLoggingOutcomeReason::Actionable => { + VerboseLoggingOutcomeReason::Actionable + } + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema => { + VerboseLoggingOutcomeReason::UnsupportedEventSchema + } + CaptureVerboseLoggingOutcomeReason::SchemaUnavailable + | CaptureVerboseLoggingOutcomeReason::IntentionalNetworkPolicyDeny + | CaptureVerboseLoggingOutcomeReason::ProxyContainment + | CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason + | CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint => return None, + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed => { + VerboseLoggingOutcomeReason::EventPayloadMalformed + } + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached => { + VerboseLoggingOutcomeReason::DecoderLimitReached + } + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding => { + VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + CaptureVerboseLoggingOutcomeReason::MissingObjectType => { + VerboseLoggingOutcomeReason::MissingObjectType + } + CaptureVerboseLoggingOutcomeReason::MissingObjectName => { + VerboseLoggingOutcomeReason::MissingObjectName + } + CaptureVerboseLoggingOutcomeReason::UnsupportedObjectType => { + VerboseLoggingOutcomeReason::UnsupportedObjectType + } + CaptureVerboseLoggingOutcomeReason::UnusableResourcePath => { + VerboseLoggingOutcomeReason::UnusableResourcePath + } + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability => { + VerboseLoggingOutcomeReason::UnresolvedCapability + } + CaptureVerboseLoggingOutcomeReason::ComActivation => { + VerboseLoggingOutcomeReason::ComActivation + } + CaptureVerboseLoggingOutcomeReason::ComInterfaceCall => { + VerboseLoggingOutcomeReason::ComInterfaceCall + } + CaptureVerboseLoggingOutcomeReason::NotActionable => { + VerboseLoggingOutcomeReason::NotActionable + } + }; + Some(VerboseLoggingSignature { + provider, + provider_guid: signature.provider_guid.clone(), + event_id: signature.event_id, + reason, + pid: signature.pid, + access_type: signature.access_type, + resource_type: signature.resource_type, + properties: signature.properties.clone(), + }) + } + + fn merge_legacy_aggregate( + summary: &mut VerboseLoggingSummary, + signature: VerboseLoggingSignature, + count: u64, + ) { + let total_before = summary.total_occurrences; + let overflow_before = summary.overflow_occurrences; + summary.record(signature.clone()); + let additional = count.saturating_sub(1); + if summary.overflow_occurrences > overflow_before { + summary.total_occurrences = summary.total_occurrences.saturating_add(additional); + summary.overflow_occurrences = summary.overflow_occurrences.saturating_add(additional); + if signature.reason.is_actionable() { + summary.actionable_overflow_occurrences = summary + .actionable_overflow_occurrences + .saturating_add(additional); + } + } else if let Ok(index) = summary + .signatures + .binary_search_by(|group| group.signature.cmp(&signature)) + { + summary.total_occurrences = summary.total_occurrences.saturating_add(additional); + summary.signatures[index].count = + summary.signatures[index].count.saturating_add(additional); + } else { + debug_assert_eq!(summary.total_occurrences, total_before); + } + } + #[allow(dead_code)] // Consumed by the stacked product-output integration. pub(crate) fn verbose_document(&self) -> CaptureVerboseLoggingDocument { CaptureVerboseLoggingDocument::new(&self.verbose_logging, &self.network_verbose_logging) @@ -769,6 +880,25 @@ mod tests { assert!(legacy.verbose_logging.aggregate_groups_truncated); } + #[test] + fn individually_oversized_actionable_signature_does_not_evict_diagnostic() { + let mut summary = CaptureVerboseLoggingSummary::default(); + let mut bytes = 0; + let mut diagnostic = + network_signature(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); + diagnostic.event_id = 999; + summary.record_with_byte_budget(diagnostic, &mut bytes, 512); + + let mut oversized = network_signature(CaptureVerboseLoggingOutcomeReason::Actionable); + oversized.properties = vec![("Large".to_string(), "x".repeat(1_024))]; + summary.record_with_byte_budget(oversized, &mut bytes, 512); + + assert_eq!(summary.signatures.len(), 1); + assert_eq!(summary.signatures[0].signature.event_id, 999); + assert_eq!(summary.overflow_occurrences, 1); + assert_eq!(summary.actionable_overflow_occurrences, 1); + } + #[test] fn version_five_document_merges_legacy_and_network_groups() { let legacy = VerboseLoggingSummary { diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index b89621f50..218be36f6 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -35,8 +35,6 @@ pub enum VerboseLoggingOutcomeReason { Actionable, /// The provider is known, but the event ID is not a supported denial schema. UnsupportedEventSchema, - /// TDH could not resolve the event schema. - SchemaUnavailable, /// The event payload was malformed or conflicted with its declared TDH schema. EventPayloadMalformed, /// A decoder safety bound prevented full payload processing. @@ -89,9 +87,6 @@ pub struct VerboseLoggingSignature { pub provider_guid: String, /// Provider-scoped ETW schema identifier. pub event_id: u16, - /// Sanitized schema name. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub event_name: Option, /// Closed exclusion category. pub reason: VerboseLoggingOutcomeReason, /// Process identifier from the event header. @@ -194,6 +189,10 @@ impl VerboseLoggingSummary { } let serialized_len = Self::serialized_signature_len(&signature); + if serialized_len > max_bytes { + self.record_overflow(signature.reason.is_actionable()); + return; + } while self.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || retained_bytes.saturating_add(serialized_len) > max_bytes { @@ -409,7 +408,6 @@ mod tests { fn aggregates_and_sorts_sanitized_signatures() { let mut summary = VerboseLoggingSummary::default(); let signature = VerboseLoggingSignature { - event_name: None, provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}".to_string(), event_id: 14, @@ -446,7 +444,6 @@ mod tests { for event_id in 0..MAX_VERBOSE_LOGGING_GROUPS as u16 { summary.record(VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, - event_name: None, provider_guid: "kernel".to_string(), event_id, reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, @@ -458,7 +455,6 @@ mod tests { } summary.record(VerboseLoggingSignature { provider: VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, - event_name: None, provider_guid: "privacy".to_string(), event_id: u16::MAX, reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, @@ -480,7 +476,6 @@ mod tests { summary.record(VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "kernel".to_string(), - event_name: None, event_id, reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, pid: 1, @@ -494,7 +489,6 @@ mod tests { provider_guid: "kernel".to_string(), event_id: u16::MAX, reason: VerboseLoggingOutcomeReason::Actionable, - event_name: None, pid: 1, access_type: Some(crate::learning_mode_core::AccessType::Read), resource_type: Some(crate::learning_mode_core::ResourceType::File), @@ -589,7 +583,6 @@ mod tests { for pid in 0..MAX_VERBOSE_LOGGING_GROUPS as u32 { summary.record_with_byte_budget( VerboseLoggingSignature { - event_name: None, provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "{A68CA8B7-004F-D7B6-A698-07E2DE0F1F5D}".to_string(), event_id: 14, @@ -616,6 +609,45 @@ mod tests { assert!(payload.len() < 64 * 1024 * 1024); } + #[test] + fn individually_oversized_actionable_signature_does_not_evict_diagnostic() { + let mut summary = VerboseLoggingSummary::default(); + let mut retained_bytes = 0; + let signature = |event_id, reason, value: String| VerboseLoggingSignature { + provider: VerboseLoggingProvider::KernelGeneral, + provider_guid: "kernel".to_string(), + event_id, + reason, + pid: 1, + access_type: None, + resource_type: None, + properties: vec![("Value".to_string(), value)], + }; + summary.record_with_byte_budget( + signature( + 14, + VerboseLoggingOutcomeReason::UnsupportedEventSchema, + "diagnostic".to_string(), + ), + &mut retained_bytes, + 512, + ); + summary.record_with_byte_budget( + signature( + 15, + VerboseLoggingOutcomeReason::Actionable, + "x".repeat(1_024), + ), + &mut retained_bytes, + 512, + ); + + assert_eq!(summary.signatures.len(), 1); + assert_eq!(summary.signatures[0].signature.event_id, 14); + assert_eq!(summary.overflow_occurrences, 1); + assert_eq!(summary.actionable_overflow_occurrences, 1); + } + #[test] fn document_round_trips() { let mut summary = VerboseLoggingSummary::default(); @@ -628,34 +660,6 @@ mod tests { assert_eq!(parsed, document); } - #[test] - fn schema_name_is_optional_and_separate_from_payload() { - let old = serde_json::json!({ - "provider": "learningModeNetworkDecision", - "providerGuid": "provider", - "eventId": 0, - "reason": "schemaUnavailable", - "pid": 42, - "properties": [["EventName", "payload-name"]] - }); - let mut signature: VerboseLoggingSignature = serde_json::from_value(old).unwrap(); - assert!(signature.event_name.is_none()); - assert!(serde_json::to_value(&signature) - .unwrap() - .get("eventName") - .is_none()); - signature.event_name = Some("schema-name".into()); - let json = serde_json::to_value(&signature).unwrap(); - assert_eq!(json["provider"], "learningModeNetworkDecision"); - assert_eq!(json["reason"], "schemaUnavailable"); - assert_eq!(json["eventName"], "schema-name"); - assert_eq!(json["properties"][0][1], "payload-name"); - assert_eq!( - serde_json::from_value::(json).unwrap(), - signature - ); - } - #[test] fn document_uses_actionable_vocabulary() { let mut summary = VerboseLoggingSummary::default(); @@ -663,7 +667,6 @@ mod tests { provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "kernel".to_string(), event_id: 14, - event_name: None, reason: VerboseLoggingOutcomeReason::Actionable, pid: 1, access_type: Some(crate::learning_mode_core::AccessType::Read), diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index c5eb322b6..c18423307 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs @@ -56,6 +56,16 @@ const INVALID_PROCESSTRACE_HANDLE: u64 = u64::MAX; const MAX_UNIQUE_DENIALS: usize = 10_000; const MAX_PROCESSED_EVENTS: usize = 1_000_000; +pub(crate) fn is_process_scoped_relog_candidate( + provider: windows::core::GUID, + event_id: u16, +) -> bool { + is_process_scoped_event(provider, event_id) + || (crate::learning_mode_windows::extractors::verbose_logging_provider_for_guid(provider) + .is_some() + && !is_learning_mode_event(provider, event_id)) +} + /// One decoded ETW event, retaining the header context the extractors need. #[cfg(test)] struct CollectedEvent { @@ -251,6 +261,14 @@ impl<'visitor> Accumulator<'visitor> { } } + fn selects(&self, provider: windows::core::GUID, event_id: u16) -> bool { + if self.process_lifetimes.is_some() { + is_process_scoped_relog_candidate(provider, event_id) + } else { + is_learning_mode_event(provider, event_id) + } + } + fn add_raw_denial(&mut self, raw: RawDenial) { self.add_raw_denial_impl(raw, true); } @@ -341,7 +359,6 @@ impl<'visitor> Accumulator<'visitor> { raw: &RawDenial, resource: &str, reason: VerboseLoggingOutcomeReason, - event_name: Option<&str>, ) { let mut properties = raw .verbose_logging_properties @@ -392,6 +409,17 @@ impl<'visitor> Accumulator<'visitor> { ); } + fn record_exclusion( + &mut self, + provider: VerboseLoggingProvider, + event_id: u16, + reason: VerboseLoggingOutcomeReason, + pid: u32, + properties: Vec<(String, String)>, + ) { + self.record_outcome(provider, event_id, reason, pid, (None, None), properties); + } + fn record_outcome( &mut self, provider: VerboseLoggingProvider, @@ -450,7 +478,7 @@ impl<'visitor> Accumulator<'visitor> { analysis: &crate::learning_mode_windows::network_extractors::NetworkDecisionAnalysis, ) { let (access_type, resource_type) = analysis.classification; - let signature = CaptureVerboseLoggingSignature { + self.record_capture_signature(CaptureVerboseLoggingSignature { provider: CaptureVerboseLoggingProvider::LearningModeNetworkDecision, provider_guid: "{71237669-21C3-4101-BD2F-FF38945D725A}".to_string(), event_id, @@ -463,9 +491,12 @@ impl<'visitor> Accumulator<'visitor> { configuration_recommendation: analysis.configuration_recommendation, network_endpoint: analysis.network_endpoint.clone(), properties: analysis.properties.clone(), - }; - if self.network_verbose_logging_actionable_only_saturated && analysis.reason.is_actionable() - { + }); + } + + fn record_capture_signature(&mut self, signature: CaptureVerboseLoggingSignature) { + let actionable = signature.reason.is_actionable(); + if self.network_verbose_logging_actionable_only_saturated && actionable { self.network_verbose_logging .record_actionable_after_saturation(signature); return; @@ -476,7 +507,7 @@ impl<'visitor> Accumulator<'visitor> { &mut self.network_verbose_logging_signature_bytes, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, ); - if analysis.reason.is_actionable() + if actionable && self.network_verbose_logging.overflow_occurrences > overflow_before && (self.network_verbose_logging.signatures.len() >= MAX_VERBOSE_LOGGING_GROUPS || self.network_verbose_logging_signature_bytes @@ -531,7 +562,7 @@ impl<'visitor> Accumulator<'visitor> { pid: u32, error: tdh_decode::DecodeError, ) { - let fatal = matches!(self.mode, CollectionMode::Raw) || error.is_schema_error(); + let fatal = matches!(self.mode, CollectionMode::Raw); if fatal { if self.decode_error.is_none() { self.decode_error = @@ -539,6 +570,14 @@ impl<'visitor> Accumulator<'visitor> { } return; } + let pid = if is_process_scoped_event(provider, event_id) { + pid + } else { + 0 + }; + if error.event_kind().is_none() && is_process_scoped_event(provider, event_id) { + self.truncated = true; + } if provider == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_PROVIDER { let reason = match error.event_kind() { Some(tdh_decode::EventDecodeKind::PayloadMalformed) => { @@ -550,7 +589,7 @@ impl<'visitor> Accumulator<'visitor> { Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding } - None => return, + None => CaptureVerboseLoggingOutcomeReason::SchemaUnavailable, }; let event_name = error .event_name() @@ -581,21 +620,16 @@ impl<'visitor> Accumulator<'visitor> { { let reason = match error.event_kind() { Some(tdh_decode::EventDecodeKind::PayloadMalformed) => { - VerboseLoggingOutcomeReason::EventPayloadMalformed + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed } Some(tdh_decode::EventDecodeKind::DecoderLimitReached) => { - VerboseLoggingOutcomeReason::DecoderLimitReached + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached } Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { - VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding } - None => return, + None => CaptureVerboseLoggingOutcomeReason::SchemaUnavailable, }; - let properties = error - .event_name() - .map(|name| vec![("EventName".to_string(), name.to_string())]) - .map(crate::learning_mode_windows::extractors::bound_properties) - .unwrap_or_default(); let classification = match event_id { crate::learning_mode_windows::extractors::LEARNING_MODE_VIOLATION_EVENT_ID => ( Some(crate::learning_mode_core::AccessType::Unknown), @@ -616,7 +650,34 @@ impl<'visitor> Accumulator<'visitor> { } _ => (None, None), }; - self.record_outcome(category, event_id, reason, pid, classification, properties); + let provider = match category { + VerboseLoggingProvider::KernelGeneral => { + CaptureVerboseLoggingProvider::KernelGeneral + } + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode => { + CaptureVerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + } + }; + let (access_type, resource_type) = classification; + self.record_capture_signature(CaptureVerboseLoggingSignature { + provider, + provider_guid: + crate::learning_mode_windows::extractors::verbose_logging_provider_guid( + category, + ), + event_id, + event_name: crate::learning_mode_windows::extractors::sanitize_event_name( + error.event_name(), + ), + reason, + pid, + access_type, + resource_type, + network_decision_reason: None, + configuration_recommendation: None, + network_endpoint: None, + properties: Vec::new(), + }); } } @@ -847,7 +908,7 @@ pub(crate) fn select_learning_mode_events_for_relogging( fn dedup_to_resources>(raws: I) -> AnalysisResult { let mut accumulator = Accumulator::analyze(); for raw in raws { - accumulator.add_raw_denial(raw, None); + accumulator.add_raw_denial(raw); } accumulator .into_analysis() @@ -1047,6 +1108,15 @@ unsafe fn process_event_record(event_record: *mut EVENT_RECORD, acc: &mut Accumu let Some(filetime) = normalized_filetime(header.TimeStamp, acc) else { return; }; + if is_network && acc.process_lifetimes.is_some() { + return; + } + if !is_network + && event_id != crate::learning_mode_windows::extractors::CAPABILITY_DENIAL_EVENT_ID + && !acc.event_in_scope(header.ProcessId, filetime) + { + return; + } analyze_filetime = Some(filetime); if !is_learning_mode_event(provider, event_id) { if !acc.event_in_scope(header.ProcessId, filetime) || !acc.begin_event() { @@ -1242,7 +1312,10 @@ fn handle_decoded_event( parts.event_id, (parts.event_id == crate::learning_mode_windows::network_extractors::NETWORK_DECISION_EVENT_ID) - .then(|| "NetworkDecisionV1".to_string()), + .then_some("NetworkDecisionV1") + .and_then(|name| { + crate::learning_mode_windows::extractors::sanitize_event_name(Some(name)) + }), &analysis, ); if let Some(raw) = denial { @@ -1256,6 +1329,18 @@ fn handle_decoded_event( else { return; }; + if !is_learning_mode_event(parts.provider, parts.event_id) { + if acc.event_in_scope(header_pid, filetime) && acc.begin_event() { + acc.record_exclusion( + category, + parts.event_id, + VerboseLoggingOutcomeReason::UnsupportedEventSchema, + header_pid, + Vec::new(), + ); + } + return; + } let Some(pid) = crate::learning_mode_windows::extractors::effective_event_pid(parts, header_pid) else { @@ -1354,12 +1439,12 @@ mod tests { ("RemoteAddress", "203.0.113.10"), ], ); - let native = resources_from_events(std::slice::from_ref(&event)); + let native = capture_resources_from_events(std::slice::from_ref(&event)); assert!(native.denials.is_empty()); - let signature = &native.verbose_logging.signatures[0].signature; + let signature = &native.network_verbose_logging.signatures[0].signature; assert_eq!( signature.provider, - VerboseLoggingProvider::LearningModeNetworkDecision + CaptureVerboseLoggingProvider::LearningModeNetworkDecision ); assert_eq!( signature.provider_guid, @@ -1368,11 +1453,11 @@ mod tests { assert_eq!(signature.pid, 0); assert_eq!( signature.reason, - VerboseLoggingOutcomeReason::UnsupportedEventSchema + CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema ); - assert_eq!(property(signature, "Reason"), "100"); - assert_eq!(property(signature, "ApplicationId"), ""); - assert_eq!(property(signature, "RemoteAddress"), "203.0.113.10"); + assert_eq!(capture_property(signature, "Reason"), "100"); + assert_eq!(capture_property(signature, "ApplicationId"), ""); + assert_eq!(capture_property(signature, "RemoteAddress"), "203.0.113.10"); let lifetimes = [ProcessLifetime { pid: SCOPED_PID, @@ -1404,11 +1489,16 @@ mod tests { unsafe { process_event_record(&mut record, &mut accumulator) }; assert_eq!(accumulator.schema_cache.schema_loads, 0); assert_eq!( - accumulator.verbose_logging.total_occurrences, + accumulator.network_verbose_logging.total_occurrences, u64::from(native) ); if native { - assert_eq!(accumulator.verbose_logging.signatures[0].signature.pid, 0); + assert_eq!( + accumulator.network_verbose_logging.signatures[0] + .signature + .pid, + 0 + ); } assert!(accumulator.relog_selected_event_indices.is_empty()); assert!(!accumulator.truncated); @@ -1428,7 +1518,7 @@ mod tests { Some("NetworkDecisionV1".into()), ), ); - let group = &accumulator.verbose_logging.signatures[0]; + let group = &accumulator.network_verbose_logging.signatures[0]; assert_eq!(group.signature.pid, 0); assert_eq!( group.signature.event_name.as_deref(), @@ -1436,7 +1526,7 @@ mod tests { ); assert_eq!( group.signature.reason, - VerboseLoggingOutcomeReason::EventPayloadMalformed + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed ); } @@ -1451,9 +1541,9 @@ mod tests { (privacy, 14, true), (privacy, 27, true), (privacy, 4907, true), - (kernel, 4907, false), - (privacy, 28, false), - (kernel, 999, false), + (kernel, 4907, true), + (privacy, 28, true), + (kernel, 999, true), (windows::core::GUID::from_u128(1), 14, false), ( windows::core::GUID::from_u128(0x3d6fa8d0_fe05_11d0_9dda_00c04fd7ba7c), @@ -1596,7 +1686,7 @@ mod tests { let analysis = accumulator.into_analysis().unwrap(); let groups = &analysis.verbose_logging.signatures; assert_eq!(analysis.verbose_logging.total_occurrences, 8); - assert_eq!(groups.len(), 4); + assert_eq!(groups.len(), 3); assert!(groups.iter().all(|group| group.count == 2)); let decoded = groups .iter() @@ -1608,20 +1698,18 @@ mod tests { assert!(decoded .iter() .all(|group| property(&group.signature, "ObjectType") == "42")); - for reason in [ - VerboseLoggingOutcomeReason::EventPayloadMalformed, - VerboseLoggingOutcomeReason::SchemaUnavailable, - ] { - let group = groups - .iter() - .find(|group| group.signature.reason == reason) - .unwrap(); - assert!(group.signature.properties.is_empty()); - assert_eq!( - group.signature.provider, - VerboseLoggingProvider::KernelGeneral - ); - } + let group = groups + .iter() + .find(|group| { + group.signature.reason == VerboseLoggingOutcomeReason::EventPayloadMalformed + }) + .unwrap(); + assert!(group.signature.properties.is_empty()); + assert_eq!( + group.signature.provider, + VerboseLoggingProvider::KernelGeneral + ); + assert_eq!(analysis.verbose_logging.overflow_occurrences, 2); assert!(analysis.denials.is_empty()); } @@ -1808,8 +1896,8 @@ mod tests { 150, ); - assert_eq!(accumulator.relog_event_count, 3); - assert_eq!(accumulator.relog_selected_event_indices, [0]); + assert_eq!(accumulator.relog_event_count, 4); + assert_eq!(accumulator.relog_selected_event_indices, [0, 2]); assert!(accumulator.decode_error.is_none()); } @@ -2123,26 +2211,21 @@ mod tests { crate::learning_mode_windows::extractors::KERNEL_GENERAL_PROVIDER, 14, 1, - tdh_decode::DecodeError::Schema("manifest unavailable".to_string()), + tdh_decode::DecodeError::SchemaNotFound, ); - let analysis = accumulator.into_analysis().unwrap(); - let group = &analysis.verbose_logging.signatures[0]; + let analysis = accumulator.into_capture_analysis().unwrap(); + let group = &analysis.network_verbose_logging.signatures[0]; assert_eq!( group.signature.reason, - VerboseLoggingOutcomeReason::SchemaUnavailable + CaptureVerboseLoggingOutcomeReason::SchemaUnavailable ); assert!(group.signature.properties.is_empty()); assert_eq!(group.count, 1); - let mut bytes = Vec::new(); - crate::learning_mode_core::write_verbose_logging_document( - &mut bytes, - &crate::learning_mode_core::VerboseLoggingDocument::new(&analysis.verbose_logging), - ) - .unwrap(); - assert!(!String::from_utf8(bytes) - .unwrap() - .contains("manifest unavailable")); + assert_eq!( + group.signature.provider, + CaptureVerboseLoggingProvider::KernelGeneral + ); } #[test] @@ -2162,8 +2245,8 @@ mod tests { true, false, ), - (kernel, 999, false, false), - (privacy, 28, false, false), + (kernel, 999, true, false), + (privacy, 28, true, false), (windows::core::GUID::from_u128(1), 14, false, false), ] { let mut accumulator = Accumulator::analyze(); @@ -2171,7 +2254,7 @@ mod tests { provider, event_id, 42, - tdh_decode::DecodeError::Schema("manifest unavailable".into()), + tdh_decode::DecodeError::SchemaNotFound, ); let event = kernel_event( 14, @@ -2196,10 +2279,8 @@ mod tests { 1 + u64::from(retained) ); assert_eq!( - analysis.verbose_logging.signatures.iter().any(|group| { - group.signature.reason == VerboseLoggingOutcomeReason::SchemaUnavailable - }), - retained + analysis.verbose_logging.overflow_occurrences, + u64::from(retained) ); let mut bytes = Vec::new(); let summary = crate::learning_mode_core::DenialSummary::new( @@ -2387,14 +2468,11 @@ mod tests { .map(|index| (format!(r"c:\data\{index}.txt"), AccessType::Read)) .collect(); - accumulator.add_raw_denial( - raw( - r"C:\data\overflow.txt", - AccessType::Read, - ResourceType::File, - ), - None, - ); + accumulator.add_raw_denial(raw( + r"C:\data\overflow.txt", + AccessType::Read, + ResourceType::File, + )); assert!( !accumulator.stop_requested, @@ -2415,18 +2493,12 @@ mod tests { // Processing continues past the cap: a further overflow candidate is // still aggregated (not silently discarded), and a duplicate of an // already-actionable denial retains the same actionable classification. - accumulator.add_raw_denial( - raw( - r"C:\data\overflow-2.txt", - AccessType::Read, - ResourceType::File, - ), - None, - ); - accumulator.add_raw_denial( - raw(r"C:\data\0.txt", AccessType::Read, ResourceType::File), - None, - ); + accumulator.add_raw_denial(raw( + r"C:\data\overflow-2.txt", + AccessType::Read, + ResourceType::File, + )); + accumulator.add_raw_denial(raw(r"C:\data\0.txt", AccessType::Read, ResourceType::File)); assert!(!accumulator.stop_requested); assert_eq!( @@ -3199,7 +3271,7 @@ mod tests { assert_eq!(analysis.denials.len(), 1); assert_eq!(analysis.denials[0].resource, r"C:\owned.txt"); - assert_eq!(analysis.verbose_logging.signatures.len(), 1); + assert_eq!(analysis.verbose_logging.signatures.len(), 2); assert!(analysis .verbose_logging .signatures @@ -3288,7 +3360,10 @@ mod tests { reason_for(28), Some(VerboseLoggingOutcomeReason::NotActionable) ); - assert_eq!(reason_for(9999), None); + assert_eq!( + reason_for(9999), + Some(VerboseLoggingOutcomeReason::UnsupportedEventSchema) + ); } #[test] @@ -3510,7 +3585,10 @@ mod tests { let out = resources_from_events(&events); assert!(out.denials.is_empty()); - assert!(out.verbose_logging.is_empty()); + assert_eq!(out.verbose_logging.signatures.len(), 2); + assert!(out.verbose_logging.signatures.iter().all(|group| { + group.signature.reason == VerboseLoggingOutcomeReason::UnsupportedEventSchema + })); } #[test] @@ -3569,10 +3647,6 @@ mod tests { VerboseLoggingProvider::KernelGeneral ); assert_eq!(actionable_group.signature.event_id, 14); - assert_eq!( - actionable_group.signature.event_name.as_deref(), - Some("AccessCheck") - ); assert_eq!( actionable_group.count, 3, "all three occurrences are retained" @@ -3658,7 +3732,6 @@ mod tests { assert_eq!(signature.reason, VerboseLoggingOutcomeReason::Actionable); assert_eq!(signature.access_type, Some(AccessType::Unknown)); assert_eq!(signature.resource_type, Some(ResourceType::Capability)); - assert_eq!(signature.event_name.as_deref(), Some("CapabilityCheck")); } fn find_signature( @@ -3672,6 +3745,19 @@ mod tests { .unwrap_or_else(|| panic!("expected a verbose logging signature for event {event_id}")) } + fn find_capture_signature( + summary: &CaptureVerboseLoggingSummary, + event_id: u16, + ) -> &crate::learning_mode_core::capture_diagnostics::CaptureVerboseLoggingAggregate { + summary + .signatures + .iter() + .find(|group| group.signature.event_id == event_id) + .unwrap_or_else(|| { + panic!("expected a capture verbose logging signature for event {event_id}") + }) + } + fn property<'a>( signature: &'a crate::learning_mode_core::VerboseLoggingSignature, name: &str, @@ -3685,6 +3771,16 @@ mod tests { .as_str() } + fn capture_property<'a>(signature: &'a CaptureVerboseLoggingSignature, name: &str) -> &'a str { + signature + .properties + .iter() + .find(|(key, _)| key == name) + .unwrap_or_else(|| panic!("expected property {name:?} in signature {signature:?}")) + .1 + .as_str() + } + #[test] fn verbose_logging_byte_budget_preserves_guarded_protocol_headroom() { let mut accumulator = Accumulator::analyze(); @@ -3699,7 +3795,7 @@ mod tests { for pid in 0..crate::learning_mode_core::MAX_VERBOSE_LOGGING_GROUPS as u32 { accumulator.record_outcome( VerboseLoggingProvider::KernelGeneral, - (14, None), + 14, VerboseLoggingOutcomeReason::Actionable, pid, (None, None), @@ -3771,7 +3867,6 @@ mod tests { assert_eq!(accumulator.network_verbose_logging.overflow_occurrences, 1); } #[test] - #[test] fn unknown_resource_signature_redacts_the_entire_file_path() { let events = vec![kernel_event( 14, @@ -3915,13 +4010,13 @@ mod tests { ), ); let out = accumulator - .into_analysis() + .into_capture_analysis() .expect("per-event decode failures are non-fatal in Analyze mode"); - let malformed = find_signature(&out.verbose_logging, 14); + let malformed = find_capture_signature(&out.network_verbose_logging, 14); assert_eq!( malformed.signature.reason, - VerboseLoggingOutcomeReason::EventPayloadMalformed + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed ); assert_eq!(malformed.signature.pid, 9); assert_eq!( @@ -3929,22 +4024,31 @@ mod tests { Some("AccessCheck") ); assert_eq!( - find_signature(&out.verbose_logging, 27).signature.reason, - VerboseLoggingOutcomeReason::DecoderLimitReached + find_capture_signature(&out.network_verbose_logging, 27) + .signature + .reason, + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached ); assert_eq!( - find_signature(&out.verbose_logging, 28).signature.reason, - VerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + find_capture_signature(&out.network_verbose_logging, 28) + .signature + .reason, + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding ); assert_eq!( - find_signature(&out.verbose_logging, 28) + find_capture_signature(&out.network_verbose_logging, 28) .signature .resource_type, Some(ResourceType::Capability) ); - assert!(out.verbose_logging.signatures.iter().all(|aggregate| { - aggregate.signature.properties.is_empty() && aggregate.signature.event_name.is_some() - })); + assert!(out + .network_verbose_logging + .signatures + .iter() + .all(|aggregate| { + aggregate.signature.properties.is_empty() + && aggregate.signature.event_name.is_some() + })); } #[test] @@ -4007,8 +4111,8 @@ mod tests { Some(r"C:\Users\private\schema".into()), ), ); - let analysis = accumulator.into_analysis().unwrap(); - let group = &analysis.verbose_logging.signatures[0]; + let analysis = accumulator.into_capture_analysis().unwrap(); + let group = &analysis.network_verbose_logging.signatures[0]; assert_eq!(group.signature.event_name.as_deref(), Some("")); assert!(group.signature.properties.is_empty()); assert_eq!(group.count, 1); diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_filter.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_filter.rs index 157f337fc..fc6682b7b 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/etl_filter.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/etl_filter.rs @@ -18,8 +18,8 @@ use windows::Win32::System::Diagnostics::Etw::{ CLSID_TraceRelogger, ITraceEvent, ITraceEventCallback, ITraceEventCallback_Impl, ITraceRelogger, }; +use crate::learning_mode_windows::etl_decode::is_process_scoped_relog_candidate; use crate::learning_mode_windows::etl_decode::select_learning_mode_events_for_relogging; -use crate::learning_mode_windows::extractors::is_process_scoped_event; use crate::learning_mode_windows::process_lifetime::{ attested_process_lifetimes, JobMembershipSnapshot, }; @@ -158,7 +158,7 @@ impl ITraceEventCallback_Impl for ProcessScopedTraceFilter_Impl { )); }; let header = &record.EventHeader; - if !is_process_scoped_event(header.ProviderId, header.EventDescriptor.Id) { + if !is_process_scoped_relog_candidate(header.ProviderId, header.EventDescriptor.Id) { return Ok(()); } let is_supported_capability_event = header.EventDescriptor.Id @@ -588,9 +588,9 @@ mod tests { }]; let selection = select_learning_mode_events_for_relogging(&source, &lifetimes).unwrap(); - assert_eq!(selection.total_event_count, 1); - assert_eq!(selection.selected_event_indices, [0]); - assert_eq!(selection.selected_event_pids, [pid]); + assert_eq!(selection.total_event_count, 3); + assert_eq!(selection.selected_event_indices, [0, 1, 2]); + assert_eq!(selection.selected_event_pids, [pid, pid, pid]); let native = crate::learning_mode_windows::EtlDenialAnalyzer .analyze_for_process_lifetimes(&source, &lifetimes) @@ -621,10 +621,11 @@ mod tests { } } for (observed, reconciled) in [ - (vec![pid], true), + (vec![pid, pid, pid], true), (vec![], false), + (vec![pid], false), (vec![pid, pid], false), - (vec![pid + 1], false), + (vec![pid + 1, pid + 1, pid + 1], false), ] { assert_eq!( relog_trace_with( diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index e42254ac8..511d5d384 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -619,6 +619,7 @@ mod tests { DecodedEventParts { provider: NETWORK_DECISION_PROVIDER, event_id: NETWORK_DECISION_EVENT_ID, + event_name: None, props, } } diff --git a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs index 3f6de1121..7194625bb 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/tdh_decode.rs @@ -1158,6 +1158,7 @@ mod tests { let parts = DecodedEventParts { provider: record.EventHeader.ProviderId, event_id: record.EventHeader.EventDescriptor.Id, + event_name: None, props, }; From aa925c7cacbd71f6a367dae181b336495d649bf0 Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 12:23:14 -0700 Subject: [PATCH 12/13] fix: validate ICMP endpoint families Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../network_extractors.rs | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs index 511d5d384..d15ff32da 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -385,7 +385,7 @@ fn exact_network_endpoint( if field_flags & FIELD_REMOTE_ADDRESS == 0 { return Ok(None); } - let remote_address = required_ip_string(parts, "RemoteAddress")?; + let remote_address = required_ip(parts, "RemoteAddress")?; let protocol = optional_u8(parts, field_flags, FIELD_PROTOCOL, "Protocol")?; let remote_port = optional_u16(parts, field_flags, FIELD_REMOTE_PORT, "RemotePort")?; let (protocol, remote_port) = match protocol { @@ -397,12 +397,14 @@ fn exact_network_endpoint( Some(1..=u16::MAX) => ("udp", remote_port), _ => return Ok(None), }, - Some(1 | 58) => ("icmp", None), + Some(1) if remote_address.is_ipv4() => ("icmp", None), + Some(58) if remote_address.is_ipv6() => ("icmp", None), + Some(1 | 58) => return Err(CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint), _ => return Ok(None), }; Ok(Some(NetworkEndpoint { protocol: protocol.to_string(), - remote_address, + remote_address: remote_address.to_string(), remote_port, })) } @@ -456,9 +458,15 @@ fn required_ip_string( parts: &DecodedEventParts, name: &'static str, ) -> Result { + required_ip(parts, name).map(|address| address.to_string()) +} + +fn required_ip( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { required_string(parts, name)? .parse::() - .map(|address| address.to_string()) .map_err(|_| CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint) } @@ -769,6 +777,32 @@ mod tests { } } + #[test] + fn tessera_icmp_recommendations_require_matching_address_family() { + for (protocol, address) in [(1, "2001:db8::1"), (58, "203.0.113.10")] { + let flags = FIELD_PROTOCOL | FIELD_REMOTE_ADDRESS | FIELD_REMOTE_PORT; + let mut parts = event( + SOURCE_TESSERA, + REASON_TESSERA_DIRECT_DEFAULT_DENY, + &[ + ("Protocol", &protocol.to_string()), + ("RemoteAddress", address), + ("RemotePort", "8"), + ], + ); + replace(&mut parts, "FieldFlags", flags.to_string()); + + let analysis = analyze_network_decision(&parts); + assert!(analysis.denial.is_none()); + assert_eq!( + analysis.reason, + CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint + ); + assert!(analysis.configuration_recommendation.is_none()); + assert!(analysis.network_endpoint.is_none()); + } + } + #[test] fn intentional_and_proxy_denials_are_verbose_only() { for (reason, expected, policy_reason, recommendation) in [ From 1212563f802a8e72a7652f649f4dad6e763e44fd Mon Sep 17 00:00:00 2001 From: "Richie Gomez (he/him)" Date: Thu, 8 Oct 2026 12:31:42 -0700 Subject: [PATCH 13/13] fix: allow Windows-only capture helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e408b60b-e267-416c-806d-0a7e119fe53f --- .../src/core/learning_mode_core/capture_diagnostics.rs | 7 +++++++ src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs | 1 + 2 files changed, 8 insertions(+) diff --git a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs index f72862017..e907679f6 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs @@ -124,6 +124,7 @@ pub(crate) struct CaptureVerboseLoggingSummary { } impl CaptureVerboseLoggingSummary { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn record_with_byte_budget( &mut self, signature: CaptureVerboseLoggingSignature, @@ -181,6 +182,7 @@ impl CaptureVerboseLoggingSummary { *retained_bytes = retained_bytes.saturating_add(serialized_len); } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn record_actionable_after_saturation( &mut self, signature: CaptureVerboseLoggingSignature, @@ -198,6 +200,7 @@ impl CaptureVerboseLoggingSummary { } } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn record_overflow(&mut self, actionable: bool, count: u64) { self.total_occurrences = self.total_occurrences.saturating_add(count); self.overflow_occurrences = self.overflow_occurrences.saturating_add(count); @@ -208,6 +211,7 @@ impl CaptureVerboseLoggingSummary { self.aggregate_groups_truncated = true; } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn evict_one_nonactionable_group(&mut self, retained_bytes: &mut usize) -> bool { let Some(index) = self .signatures @@ -316,6 +320,7 @@ pub(crate) struct CaptureAnalysis { } impl CaptureAnalysis { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn into_legacy(mut self) -> AnalysisResult { for aggregate in self.network_verbose_logging.signatures { if let Some(signature) = Self::capture_signature_to_legacy(&aggregate.signature) { @@ -363,6 +368,7 @@ impl CaptureAnalysis { } } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn capture_signature_to_legacy( signature: &CaptureVerboseLoggingSignature, ) -> Option { @@ -431,6 +437,7 @@ impl CaptureAnalysis { }) } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] fn merge_legacy_aggregate( summary: &mut VerboseLoggingSummary, signature: VerboseLoggingSignature, diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index 218be36f6..d7945aba2 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -221,6 +221,7 @@ impl VerboseLoggingSummary { /// Records an actionable outcome after the caller has established that /// retention is saturated and every retained group is actionable. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn record_actionable_after_saturation( &mut self, signature: VerboseLoggingSignature,