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 new file mode 100644 index 000000000..e907679f6 --- /dev/null +++ b/src/mxc-sdk/src/core/learning_mode_core/capture_diagnostics.rs @@ -0,0 +1,1128 @@ +// 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, VerboseLoggingSignature, 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 { + 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", deny_unknown_fields)] +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", deny_unknown_fields)] +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", deny_unknown_fields)] +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 { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + 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 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); + 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 + { + if !actionable || !self.evict_one_nonactionable_group(retained_bytes) { + self.record_overflow(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); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + 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), + } + } + + #[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); + if actionable { + self.actionable_overflow_occurrences = + self.actionable_overflow_occurrences.saturating_add(count); + } + 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 + .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", deny_unknown_fields)] +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", deny_unknown_fields)] +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 { + #[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) { + 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, + verbose_logging: self.verbose_logging, + } + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + 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(), + }) + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + 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) + } +} + +#[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 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, + } +} + +#[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 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 { + 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 + ); + } + + #[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_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 823afecea..f181028fb 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 @@ -80,7 +79,8 @@ pub enum AccessType { /// 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 /// @@ -110,7 +110,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, @@ -121,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 eaf366681..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 @@ -25,8 +25,6 @@ pub enum VerboseLoggingProvider { KernelGeneral, /// Microsoft-Windows-Privacy-Auditing-PermissiveLearningMode. PrivacyAuditingPermissiveLearningMode, - /// Microsoft-Windows-LearningMode-NetworkDecision. - LearningModeNetworkDecision, } /// Closed reason describing how a decoder outcome was handled. @@ -37,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. @@ -63,6 +59,16 @@ pub enum VerboseLoggingOutcomeReason { NotActionable, } +/// 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 { /// Returns whether this outcome represents a valid denial candidate. #[must_use] @@ -81,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. @@ -186,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 { @@ -212,6 +219,26 @@ 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. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + 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); @@ -382,7 +409,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, @@ -419,7 +445,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, @@ -431,7 +456,6 @@ mod tests { } summary.record(VerboseLoggingSignature { provider: VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, - event_name: None, provider_guid: "privacy".to_string(), event_id: u16::MAX, reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, @@ -453,7 +477,6 @@ mod tests { summary.record(VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "kernel".to_string(), - event_name: None, event_id, reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, pid: 1, @@ -467,7 +490,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), @@ -512,6 +534,46 @@ mod tests { .any(|group| { group.signature.reason == VerboseLoggingOutcomeReason::ComActivation })); } + #[test] + fn saturated_actionable_groups_still_count_retained_repeats() { + let mut summary = VerboseLoggingSummary::default(); + 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 { + 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_actionable_after_saturation(retained); + summary.record_actionable_after_saturation(VerboseLoggingSignature { + provider: VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode, + provider_guid: "privacy".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] fn byte_budget_leaves_guarded_analysis_protocol_headroom() { let mut summary = VerboseLoggingSummary::default(); @@ -522,7 +584,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, @@ -549,6 +610,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(); @@ -561,34 +661,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(); @@ -596,7 +668,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/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 43fa86493..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 @@ -29,9 +29,13 @@ 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, + VerboseLoggingSummary, MAX_VERBOSE_LOGGING_GROUPS, MAX_VERBOSE_LOGGING_SIGNATURE_BYTES, }; use windows::core::PWSTR; use windows::Win32::System::Diagnostics::Etw::{ @@ -52,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 { @@ -149,6 +163,10 @@ struct Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary, 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> { @@ -173,6 +191,10 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), 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, } } @@ -204,6 +226,10 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), 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, } } @@ -228,18 +254,30 @@ impl<'visitor> Accumulator<'visitor> { verbose_logging: VerboseLoggingSummary::default(), 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) + is_process_scoped_relog_candidate(provider, event_id) } else { is_learning_mode_event(provider, event_id) } } - fn add_raw_denial(&mut self, raw: RawDenial, event_name: Option<&str>) { + fn add_raw_denial(&mut self, raw: RawDenial) { + self.add_raw_denial_impl(raw, true); + } + + 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; } @@ -247,12 +285,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) => { @@ -260,24 +299,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(), @@ -296,6 +337,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)); @@ -317,7 +359,6 @@ impl<'visitor> Accumulator<'visitor> { raw: &RawDenial, resource: &str, reason: VerboseLoggingOutcomeReason, - event_name: Option<&str>, ) { let mut properties = raw .verbose_logging_properties @@ -355,9 +396,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)), @@ -365,10 +409,21 @@ 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, - event: (u16, Option<&str>), + event_id: u16, reason: VerboseLoggingOutcomeReason, pid: u32, classification: ( @@ -383,19 +438,88 @@ 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, 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.len() >= MAX_VERBOSE_LOGGING_GROUPS + || self.verbose_logging_signature_bytes >= MAX_VERBOSE_LOGGING_SIGNATURE_BYTES) + && self + .verbose_logging + .signatures + .iter() + .all(|group| group.signature.reason.is_actionable()) + { + self.verbose_logging_actionable_only_saturated = true; + } + } + + 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; + self.record_capture_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(), + }); + } + + 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; + } + 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 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 + .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 { @@ -409,6 +533,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; } @@ -445,35 +570,66 @@ 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) - { + if error.event_kind().is_none() && 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 => CaptureVerboseLoggingOutcomeReason::SchemaUnavailable, + }; + 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) => { + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed + } + Some(tdh_decode::EventDecodeKind::DecoderLimitReached) => { + CaptureVerboseLoggingOutcomeReason::DecoderLimitReached + } + Some(tdh_decode::EventDecodeKind::UnsupportedPropertyEncoding) => { + CaptureVerboseLoggingOutcomeReason::UnsupportedPropertyEncoding + } + None => CaptureVerboseLoggingOutcomeReason::SchemaUnavailable, + }; let classification = match event_id { crate::learning_mode_windows::extractors::LEARNING_MODE_VIOLATION_EVENT_ID => ( Some(crate::learning_mode_core::AccessType::Unknown), @@ -494,11 +650,38 @@ impl<'visitor> Accumulator<'visitor> { } _ => (None, None), }; - self.record_outcome(category, event, reason, pid, classification, Vec::new()); + 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(), + }); } } - fn into_analysis(self) -> Result { + fn into_capture_analysis(self) -> Result { if let Some(error) = self.decode_error { return Err(AnalyzeError::Decode(error)); } @@ -507,11 +690,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, ) { @@ -531,6 +719,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. @@ -589,9 +791,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}" + ))), + } } } @@ -604,7 +815,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)] @@ -612,13 +823,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") } @@ -684,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() @@ -872,16 +1096,58 @@ 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; }; + 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); - 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; } } @@ -1034,22 +1300,54 @@ 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_some("NetworkDecisionV1") + .and_then(|name| { + crate::learning_mode_windows::extractors::sanitize_event_name(Some(name)) + }), + &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 { 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 { 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), @@ -1071,18 +1369,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), @@ -1141,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, @@ -1155,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, @@ -1191,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); @@ -1215,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(), @@ -1223,7 +1526,7 @@ mod tests { ); assert_eq!( group.signature.reason, - VerboseLoggingOutcomeReason::EventPayloadMalformed + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed ); } @@ -1238,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), @@ -1383,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() @@ -1395,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()); } @@ -1595,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()); } @@ -1910,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] @@ -1949,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(); @@ -1958,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, @@ -1983,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( @@ -2013,8 +2307,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(), } } @@ -2046,6 +2341,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![ @@ -2153,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, @@ -2181,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!( @@ -2339,6 +2645,241 @@ 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, + &properties, + ) + } + + #[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 = capture_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"); + let network_signature = analysis + .network_verbose_logging + .signatures + .iter() + .find(|aggregate| { + aggregate.signature.provider + == CaptureVerboseLoggingProvider::LearningModeNetworkDecision + && aggregate.signature.reason == CaptureVerboseLoggingOutcomeReason::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 + .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] + 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 malformed = common + .into_iter() + .chain([ + ("Reason", "100"), + ("FieldFlags", "16"), + ("Direction", "invalid"), + ]) + .collect::>(); + + 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 [ + CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason, + CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed, + ] { + let aggregate = analysis + .network_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] @@ -2730,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 @@ -2819,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] @@ -3041,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] @@ -3100,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" @@ -3189,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( @@ -3203,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, @@ -3216,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(); @@ -3230,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), @@ -3243,6 +3808,64 @@ 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] fn unknown_resource_signature_redacts_the_entire_file_path() { let events = vec![kernel_event( @@ -3387,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!( @@ -3401,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] @@ -3479,11 +4111,51 @@ 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); } } + + #[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/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/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 145e59f61..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,7 +146,6 @@ pub fn extract_denial( if !is_learning_mode_event(parts.provider, parts.event_id) { return Err(VerboseLoggingOutcomeReason::UnsupportedEventSchema); } - match parts.event_id { ACCESS_CHECK_EVENT_ID | PRIVACY_ACCESS_CHECK_EVENT_ID => { build_denial_from_access_check(parts, pid, filetime, provider) @@ -167,6 +166,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 +248,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( @@ -275,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) - } } } @@ -434,6 +435,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") @@ -778,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), }) } @@ -868,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), }) } @@ -935,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), }) } @@ -1667,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] @@ -2247,6 +2251,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/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..d15ff32da --- /dev/null +++ b/src/mxc-sdk/src/core/learning_mode_windows/network_extractors.rs @@ -0,0 +1,1079 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Extractors for the OS-managed Learning Mode WFP decision event. + +use crate::learning_mode_core::{ + capture_diagnostics::{ + CaptureVerboseLoggingOutcomeReason, ConfigurationRecommendation, NetworkDecisionReason, + NetworkEndpoint, + }, + AccessType, ResourceType, +}; +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}"; + +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 { + if parts.provider != NETWORK_DECISION_PROVIDER || parts.event_id != NETWORK_DECISION_EVENT_ID { + 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")?; + 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(CaptureVerboseLoggingOutcomeReason::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(CaptureVerboseLoggingOutcomeReason::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(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); + } + if reason != REASON_APP_ISOLATION_MISSING_CAPABILITY { + return Err(CaptureVerboseLoggingOutcomeReason::UnknownNetworkReason); + } + if field_flags & FIELD_CAPABILITY_ID == 0 { + return Ok(network_diagnostic( + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability, + NetworkDecisionReason::AppIsolationMissingCapability, + None, + None, + ResourceType::Capability, + )); + } + 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 Ok(network_diagnostic( + CaptureVerboseLoggingOutcomeReason::UnresolvedCapability, + NetworkDecisionReason::AppIsolationMissingCapability, + None, + None, + ResourceType::Capability, + )); + } + }; + + 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: 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(), + }) +} + +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(CaptureVerboseLoggingOutcomeReason::UnsupportedEventSchema); + } + + 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, + (network_decision_reason != NetworkDecisionReason::DirectDefaultDeny) + .then_some(recommendation) + .flatten(), + None, + ResourceType::Network, + )); + } + }; + 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 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 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(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 { + 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) 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.to_string(), + remote_port, + })) +} + +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() + }; + let port = match protocol { + Some(1 | 58) => None, + _ => port, + }; + 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(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) +} + +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_err(|_| CaptureVerboseLoggingOutcomeReason::IncompleteNetworkEndpoint) +} + +fn required_u8( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u8) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) +} + +fn required_u16( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u16) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) +} + +fn required_u32( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u32) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) +} + +fn required_u64( + parts: &DecodedEventParts, + name: &'static str, +) -> Result { + property(parts, name) + .and_then(parse_u64) + .ok_or(CaptureVerboseLoggingOutcomeReason::EventPayloadMalformed) +} + +fn optional_string( + parts: &DecodedEventParts, + flags: u32, + flag: u32, + name: &'static str, +) -> Result, CaptureVerboseLoggingOutcomeReason> { + 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, CaptureVerboseLoggingOutcomeReason> { + 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, CaptureVerboseLoggingOutcomeReason> { + 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, CaptureVerboseLoggingOutcomeReason> { + 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::*; + + 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()), + ("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, + event_name: None, + 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_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", "3")], + ); + replace( + &mut parts, + "SublayerGuid", + APP_ISOLATION_SUBLAYER.to_string(), + ); + replace(&mut parts, "FieldFlags", FIELD_CAPABILITY_ID.to_string()); + + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnresolvedCapability + ); + } + + #[test] + 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( + 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); + } + + #[test] + 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, + &[ + ("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_eq!(denial.object_name, "ip://203.0.113.10"); + } + } + + #[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 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 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 [ + ( + 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.clone(); + 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()); + + 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] + 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 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( + 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 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, &[]); + + assert_eq!( + extract_network_denial(&parts).unwrap_err(), + VerboseLoggingExclusionReason::UnknownNetworkReason + ); + } +} 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..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 @@ -1032,6 +1032,7 @@ fn wide_str_at(buf: &[u8], offset: u32) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::learning_mode_windows::network_extractors; fn utf16_bytes(value: &str) -> Vec { value @@ -1042,14 +1043,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 +1070,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 +1095,80 @@ 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 = 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 { + provider: record.EventHeader.ProviderId, + event_id: record.EventHeader.EventDescriptor.Id, + event_name: None, + props, + }; + + let denial = network_extractors::analyze_network_decision(&parts) + .denial + .expect("network denial"); + + assert_eq!(denial.object_name, "tcp://203.0.113.10:443"); + } + #[test] fn tdh_info_buffer_is_aligned_and_initialized() { let mut buffer = TdhInfoBuffer::new(std::mem::size_of::() + 7); 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..f20bfcf61 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, NetworkEndpoint, 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, @@ -355,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) @@ -370,7 +388,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 +396,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 +414,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")); } @@ -404,6 +425,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(); @@ -411,41 +437,23 @@ mod tests { assert!(!serialized.contains(injected)); assert_eq!( projected.signatures[0].signature.provider_guid, - canonical_provider_guid(VerboseLoggingProvider::KernelGeneral) - ); - assert!(projected.signatures[0].signature.properties.is_empty()); - } - - #[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_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}" + canonical_provider_guid(CaptureVerboseLoggingProvider::KernelGeneral) ); - 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")); + assert!(projected.signatures[0].signature.properties.is_empty()); + assert!(projected.signatures[0].signature.network_endpoint.is_none()); } #[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; @@ -456,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}" + ); + } } 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" + ); +}