diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index 3c6e1e55e8..7b16eccf65 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -237,7 +237,7 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ```json { - "version": 2, + "version": 3, "signatures": [ { "signature": { @@ -296,6 +296,10 @@ reason and their sanitized event properties: converted to a safe absolute DOS or UNC path. For example, `\Device\MountPointManager` is useful Devices-namespace evidence, but it is not a directly authorable filesystem grant. +- `comActivation` and `comInterfaceCall` mark observed classic COM class + activation and interface-call access checks: denied under `block`, recorded + and allowed under `allow`. The CLSID or IID is retained. A missing identifier + is `missingObjectName`; an invalid one is `eventPayloadMalformed`. - `unsupportedObjectType` means the event names a resource outside the supported diagnostic model. Examples include `\BaseNamedObjects` as a Directory, ALPC Ports such as @@ -312,7 +316,7 @@ Unknown event IDs from known Learning Mode providers are classified as PID without attempting an unsupported TDH payload decode. Per-event TDH failures use closed diagnostic reasons: -`eventPayloadMalformed` means the payload conflicts with its declared schema, +`eventPayloadMalformed` means the payload is malformed or conflicts with its declared schema, `decoderLimitReached` means a nesting/element/work safety bound stopped decoding, and `unsupportedPropertyEncoding` means the decoder cannot consume that property shape. When TDH exposes it, the schema-declared name is retained 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 a1fa8b0933..d0f28ff186 100644 --- a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs +++ b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs @@ -35,7 +35,7 @@ pub enum VerboseLoggingOutcomeReason { Actionable, /// The provider is known, but the event ID is not a supported denial schema. UnsupportedEventSchema, - /// The event payload conflicted with its declared TDH schema. + /// The event payload was malformed or conflicted with its declared TDH schema. EventPayloadMalformed, /// A decoder safety bound prevented full payload processing. DecoderLimitReached, @@ -51,6 +51,10 @@ pub enum VerboseLoggingOutcomeReason { UnusableResourcePath, /// A capability event did not contain a usable capability denial. UnresolvedCapability, + /// Classic COM class activation. + ComActivation, + /// Classic COM interface call. + ComInterfaceCall, /// The event was valid but did not describe an actionable denial. NotActionable, } @@ -303,7 +307,7 @@ pub struct VerboseLoggingDocumentSummary { impl VerboseLoggingDocument { /// Current verbose logging document schema version. - pub const VERSION: u32 = 2; + pub const VERSION: u32 = 3; /// Builds an on-disk document from decoder aggregate state. #[must_use] @@ -466,6 +470,36 @@ mod tests { assert_eq!(summary.actionable_overflow_occurrences, 0); } + #[test] + fn com_signature_overflow_is_not_counted_as_actionable() { + let mut summary = VerboseLoggingSummary::default(); + let mut signature: VerboseLoggingSignature = serde_json::from_value(serde_json::json!({ + "provider": "kernelGeneral", + "providerGuid": "kernel", + "eventId": 0, + "reason": "unsupportedEventSchema", + "pid": 1, + "properties": [] + })) + .unwrap(); + for event_id in 0..MAX_VERBOSE_LOGGING_GROUPS as u16 { + signature.event_id = event_id; + summary.record(signature.clone()); + } + + signature.event_id = u16::MAX; + signature.reason = VerboseLoggingOutcomeReason::ComActivation; + signature.resource_type = Some(crate::learning_mode_core::ResourceType::Other); + summary.record(signature); + + assert_eq!(summary.overflow_occurrences, 1); + assert_eq!(summary.actionable_overflow_occurrences, 0); + assert!(!summary + .signatures + .iter() + .any(|group| { group.signature.reason == VerboseLoggingOutcomeReason::ComActivation })); + } + #[test] fn byte_budget_leaves_guarded_analysis_protocol_headroom() { let mut summary = VerboseLoggingSummary::default(); @@ -531,12 +565,26 @@ mod tests { summary.mark_actionable_limit_reached(); let value = serde_json::to_value(VerboseLoggingDocument::new(&summary)).unwrap(); - assert_eq!(value["version"], 2); + assert_eq!(value["version"], 3); assert_eq!(value["signatures"][0]["signature"]["reason"], "actionable"); assert_eq!(value["summary"]["actionableOverflowOccurrences"], 2); assert_eq!(value["summary"]["actionableLimitReached"], true); } + #[test] + fn document_serializes_distinct_nonactionable_com_reasons() { + for (reason, expected) in [ + (VerboseLoggingOutcomeReason::ComActivation, "comActivation"), + ( + VerboseLoggingOutcomeReason::ComInterfaceCall, + "comInterfaceCall", + ), + ] { + assert!(!reason.is_actionable()); + assert_eq!(serde_json::to_value(reason).unwrap(), expected); + } + } + #[test] fn sibling_path_replaces_the_actionable_extension() { assert_eq!( 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 f34cb05d4d..a7df1aae02 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 @@ -1847,6 +1847,165 @@ mod tests { assert_eq!(out[2].resource_type, ResourceType::Ui); } + #[test] + fn com_access_checks_are_distinct_verbose_only_outcomes_in_both_modes() { + let activation_clsid = "{A47979D2-C419-11D9-A5B4-001185AD2B89}"; + let call_iid = "{00000132-0000-0000-C000-000000000046}"; + let events = vec![ + kernel_event( + 14, + 100, + 1, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", activation_clsid), + ("AccessMask", "0x1"), + ], + ), + permissive_event( + 14, + 101, + 2, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", "\"{a47979d2-c419-11d9-a5b4-001185ad2b89}\""), + ("AccessMask", "0xffffffff"), + ], + ), + permissive_event( + 4907, + 102, + 3, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"ComCallOnInterface\""), + ("ObjectName", call_iid), + ("AccessMask", "0x2"), + ], + ), + ]; + + let analysis = resources_from_events(&events); + + assert!(analysis.denials.is_empty()); + let activation_signatures = analysis + .verbose_logging + .signatures + .iter() + .filter(|group| property(&group.signature, "ObjectType") == "ComActivationForClass") + .collect::>(); + assert_eq!(activation_signatures.len(), 2); + assert!(activation_signatures.iter().all(|group| { + group.signature.reason == VerboseLoggingOutcomeReason::ComActivation + && group.signature.resource_type == Some(ResourceType::Other) + && group.signature.access_type.is_none() + && group.count == 1 + })); + assert!(activation_signatures + .iter() + .any(|group| property(&group.signature, "ObjectName") == activation_clsid)); + assert!(activation_signatures.iter().any(|group| { + group.signature.provider + == VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + })); + + let call = analysis + .verbose_logging + .signatures + .iter() + .find(|group| property(&group.signature, "ObjectType") == "ComCallOnInterface") + .expect("COM interface call should remain in verbose logging"); + assert_eq!( + call.signature.reason, + VerboseLoggingOutcomeReason::ComInterfaceCall + ); + assert_eq!( + call.signature.provider, + VerboseLoggingProvider::PrivacyAuditingPermissiveLearningMode + ); + assert_eq!(call.signature.event_id, 4907); + assert_eq!(call.signature.resource_type, Some(ResourceType::Other)); + assert!(call.signature.access_type.is_none()); + assert_eq!(property(&call.signature, "ObjectName"), call_iid); + } + + #[test] + fn com_checks_do_not_change_actionable_output() { + let file = || { + kernel_event( + 14, + 100, + 2, + &[ + ("ObjectType", "\"File\""), + ("ObjectName", r"C:\kept.txt"), + ("AccessMask", "0x1"), + ], + ) + }; + let com = |filetime| { + kernel_event( + 14, + 100, + filetime, + &[ + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", "{A47979D2-C419-11D9-A5B4-001185AD2B89}"), + ], + ) + }; + + let without = resources_from_events(&[file()]); + let with = resources_from_events(&[com(1), file(), com(3)]); + + assert_eq!(without.denials.len(), 1); + assert_eq!(with.denials, without.denials); + assert_eq!( + with.verbose_logging.signatures.len(), + without.verbose_logging.signatures.len() + 1 + ); + assert_eq!( + with.verbose_logging.total_occurrences, + without.verbose_logging.total_occurrences + 2 + ); + } + + #[test] + fn malformed_com_identifier_remains_classified_verbose_diagnostic() { + for object_name in [r"C:\Users\alice\secret.txt", "alice@example.com"] { + let events = vec![kernel_event( + 14, + 42, + 1, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", object_name), + ("AccessMask", "0x1"), + ], + )]; + + let analysis = resources_from_events(&events); + + assert!(analysis.denials.is_empty()); + assert_eq!(analysis.verbose_logging.signatures.len(), 1); + let signature = &analysis.verbose_logging.signatures[0].signature; + assert_eq!( + signature.reason, + VerboseLoggingOutcomeReason::EventPayloadMalformed + ); + assert_eq!(signature.resource_type, Some(ResourceType::Other)); + assert!(signature.access_type.is_none()); + assert_eq!(property(signature, "ObjectType"), "ComActivationForClass"); + assert_eq!( + property(signature, "ObjectName"), + crate::learning_mode_windows::extractors::REDACTED_PATH + ); + } + } + #[test] fn unidentified_capability_events_are_omitted() { let events = vec![ 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 7fd460e585..08ea72e262 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -76,6 +76,8 @@ pub(crate) const ACCESS_CHECK_EVENT_ID: u16 = 14; pub(crate) const LEARNING_MODE_VIOLATION_EVENT_ID: u16 = 27; pub(crate) const CAPABILITY_DENIAL_EVENT_ID: u16 = 28; pub(crate) const PRIVACY_ACCESS_CHECK_EVENT_ID: u16 = 4907; +const COM_ACTIVATION_OBJECT_TYPE: &str = "ComActivationForClass"; +const COM_CALL_OBJECT_TYPE: &str = "ComCallOnInterface"; /// Pre-decoded event payload handed to the extractors. /// @@ -197,6 +199,9 @@ pub(crate) fn verbose_logging_classification( ), Some(ResourceType::Other), ), + object_type if com_outcome_reason(object_type).is_some() => { + (None, Some(ResourceType::Other)) + } "" => (Some(AccessType::Unknown), Some(ResourceType::Capability)), _ => (None, None), } @@ -420,6 +425,9 @@ fn looks_like_file_path_property(name: &str, value: &str, object_type: Option<&s if !normalized.equals("objectname") && !normalized.equals("resource") { return false; } + if object_type.is_some_and(|object_type| com_outcome_reason(object_type).is_some()) { + return !is_guid_identifier(value); + } if object_type.is_some_and(|object_type| object_type.eq_ignore_ascii_case("File")) { return true; } @@ -633,7 +641,10 @@ pub(crate) fn sanitize_properties(props: &[(String, String)]) -> Vec<(String, St /// [`VerboseLoggingOutcomeReason::MissingObjectName`]; capability: an /// unidentified brokered check, /// [`VerboseLoggingOutcomeReason::UnresolvedCapability`] — [`crate::learning_mode_windows::capability_dacl`] -/// may still recover it from the event's DACL payload), or a self-access, +/// may still recover it from the event's DACL payload), a COM check +/// ([`VerboseLoggingOutcomeReason::ComActivation`] or +/// [`VerboseLoggingOutcomeReason::ComInterfaceCall`]; an invalid CLSID/IID is +/// [`VerboseLoggingOutcomeReason::EventPayloadMalformed`]), or a self-access, /// non-read registry, or recognized named-object check that isn't actionable /// ([`VerboseLoggingOutcomeReason::NotActionable`]). pub fn build_denial_from_access_check( @@ -645,6 +656,7 @@ pub fn build_denial_from_access_check( let object_type = find_prop(&parts.props, "ObjectType") .ok_or(VerboseLoggingOutcomeReason::MissingObjectType)?; let object_type_str = object_type.trim_matches('"'); + let com_reason = com_outcome_reason(object_type_str); let resource_type = match object_type_str { "File" => ResourceType::File, @@ -652,6 +664,7 @@ pub fn build_denial_from_access_check( "Section" | "SymbolicLink" | "Timer" => ResourceType::Other, // A present-but-empty object type is a brokered-capability check. "" => ResourceType::Capability, + _ if com_reason.is_some() => ResourceType::Other, _ => return Err(VerboseLoggingOutcomeReason::UnsupportedObjectType), }; @@ -668,6 +681,13 @@ pub fn build_denial_from_access_check( (_, Some(name)) => name, }; + if let Some(reason) = com_reason { + if !is_guid_identifier(&object_name) { + return Err(VerboseLoggingOutcomeReason::EventPayloadMalformed); + } + return Err(reason); + } + if resource_type == ResourceType::File { let app_path = find_prop(&parts.props, "AppPath") .or_else(|| find_prop(&parts.props, "ApplicationPath")) @@ -1004,6 +1024,22 @@ fn find_prop<'a>(props: &'a [(String, String)], name: &str) -> Option<&'a String props.iter().find(|(k, _)| k == name).map(|(_, v)| v) } +fn com_outcome_reason(object_type: &str) -> Option { + match object_type { + COM_ACTIVATION_OBJECT_TYPE => Some(VerboseLoggingOutcomeReason::ComActivation), + COM_CALL_OBJECT_TYPE => Some(VerboseLoggingOutcomeReason::ComInterfaceCall), + _ => None, + } +} + +fn is_guid_identifier(value: &str) -> bool { + let value = value + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + .unwrap_or(value); + GUID::try_from(value).is_ok() +} + #[cfg(test)] mod tests { use super::*; @@ -1352,6 +1388,92 @@ mod tests { } } + #[test] + fn access_check_com_denials_use_distinct_verbose_reasons() { + for (object_type, object_name, mode, expected_reason) in [ + ( + COM_ACTIVATION_OBJECT_TYPE, + "{A47979D2-C419-11D9-A5B4-001185AD2B89}", + "\"Normal\"", + VerboseLoggingOutcomeReason::ComActivation, + ), + ( + COM_CALL_OBJECT_TYPE, + "{00000132-0000-0000-C000-000000000046}", + "\"Permissive\"", + VerboseLoggingOutcomeReason::ComInterfaceCall, + ), + ] { + let p = parts( + 14, + &[ + ("Mode", mode), + ("ObjectType", object_type), + ("ObjectName", object_name), + ("AccessMask", "0xffffffff"), + ], + ); + + assert_eq!(extract_denial(&p, 42, FIXED_FILETIME), Err(expected_reason)); + assert_eq!( + verbose_logging_classification(&p), + (None, Some(ResourceType::Other)) + ); + } + } + + #[test] + fn access_check_com_denials_require_guid_identifier() { + for (object_name, expected) in [ + (None, VerboseLoggingOutcomeReason::MissingObjectName), + (Some("\"\""), VerboseLoggingOutcomeReason::MissingObjectName), + ( + Some("\"not-a-guid\""), + VerboseLoggingOutcomeReason::EventPayloadMalformed, + ), + ( + Some("\"{00000132-0000-0000-C000-000000000046\""), + VerboseLoggingOutcomeReason::EventPayloadMalformed, + ), + ( + Some("\"00000132-0000-0000-C000-000000000046}\""), + VerboseLoggingOutcomeReason::EventPayloadMalformed, + ), + ( + Some("\"00000132-0000-0000-C000-000000000046\""), + VerboseLoggingOutcomeReason::ComInterfaceCall, + ), + ] { + let mut properties = vec![("ObjectType", COM_CALL_OBJECT_TYPE)]; + if let Some(object_name) = object_name { + properties.push(("ObjectName", object_name)); + } + let p = parts(14, &properties); + + assert_eq!(extract_denial(&p, 42, FIXED_FILETIME), Err(expected)); + assert_eq!( + verbose_logging_classification(&p), + (None, Some(ResourceType::Other)) + ); + } + } + + #[test] + fn access_check_rpc_interface_remains_unsupported() { + let p = parts( + 14, + &[ + ("ObjectType", "\"RPC Interface\""), + ("ObjectName", "\"f6beaff7-1e19-4fbb-9f8f-b89e2018337c\""), + ], + ); + assert_eq!( + extract_denial(&p, 1, FIXED_FILETIME), + Err(VerboseLoggingOutcomeReason::UnsupportedObjectType) + ); + assert_eq!(verbose_logging_classification(&p), (None, None)); + } + #[test] fn access_check_unrecognized_object_type_is_dropped() { let p = parts( 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 ea96890271..6e9ae26088 100644 --- a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs @@ -215,13 +215,17 @@ mod tests { }; use crate::mxc_common::models::{CaptureDenialsOutput, SandboxOutputMetadata}; - fn aggregate(event_id: u16, value: &str) -> VerboseLoggingAggregate { + fn aggregate_with_reason( + event_id: u16, + value: &str, + reason: VerboseLoggingOutcomeReason, + ) -> VerboseLoggingAggregate { VerboseLoggingAggregate { signature: VerboseLoggingSignature { provider: VerboseLoggingProvider::KernelGeneral, provider_guid: "{a68ca8b7-004f-d7b6-a698-07e2de0f1f5d}".to_string(), event_id, - reason: VerboseLoggingOutcomeReason::UnsupportedEventSchema, + reason, pid: 42, access_type: None, resource_type: None, @@ -231,6 +235,14 @@ mod tests { } } + fn aggregate(event_id: u16, value: &str) -> VerboseLoggingAggregate { + aggregate_with_reason( + event_id, + value, + VerboseLoggingOutcomeReason::UnsupportedEventSchema, + ) + } + fn document(signatures: Vec) -> VerboseLoggingDocument { VerboseLoggingDocument { version: VerboseLoggingDocument::VERSION, @@ -340,6 +352,28 @@ mod tests { })); } + #[test] + fn telemetry_preserves_com_reason_but_strips_clsid() { + let mut document = document(vec![aggregate_with_reason( + 14, + "{A47979D2-C419-11D9-A5B4-001185AD2B89}", + VerboseLoggingOutcomeReason::ComActivation, + )]); + document.signatures[0].signature.resource_type = + Some(crate::learning_mode_core::ResourceType::Other); + + document = project_for_telemetry(document); + + let signature = &document.signatures[0].signature; + assert_eq!(signature.reason, VerboseLoggingOutcomeReason::ComActivation); + assert_eq!( + signature.resource_type, + Some(crate::learning_mode_core::ResourceType::Other) + ); + assert!(signature.access_type.is_none()); + assert!(signature.properties.is_empty()); + } + #[test] fn malformed_artifact_never_falls_back_to_raw_bytes() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_telemetry_etw.rs b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_telemetry_etw.rs index d1b15feb30..aadc1ce2c7 100644 --- a/src/mxc-sdk/tests/wxc_e2e_tests_e2e_telemetry_etw.rs +++ b/src/mxc-sdk/tests/wxc_e2e_tests_e2e_telemetry_etw.rs @@ -476,7 +476,7 @@ fn test_verbose_denials_etw_payload_honors_consent() { &std::fs::read(&verbose_path).expect("failed to read verbose artifact"), ) .expect("verbose artifact was not valid JSON"); - assert_eq!(verbose["version"], 2); + assert_eq!(verbose["version"], 3); let granted_dump = decode_trace(&granted_etl, &workdir) .expect("tracerpt produced no output for the verbose telemetry run");