From 42b3217380720ac8957a005be29728466de15c77 Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Mon, 5 Oct 2026 14:01:24 -0700 Subject: [PATCH 1/7] Add COM diagnostics to Learning Mode Co-authored-by: Richie Gomez (he/him) Copilot-Session: 7758b99e-b7a0-4060-baf9-268db306b58b --- docs/learning-mode/capabilities.md | 57 +++++-- .../learning_mode_core/src/verbose_logging.rs | 52 +++++- .../windows/src/etl_decode.rs | 104 ++++++++++++ .../windows/src/extractors.rs | 149 ++++++++++++++++-- src/core/mxc_engine/src/verbose_telemetry.rs | 38 ++++- .../wxc_e2e_tests/tests/e2e_telemetry_etw.rs | 2 +- 6 files changed, 375 insertions(+), 27 deletions(-) diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 1c406d233..d598dd066 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -214,10 +214,10 @@ sandbox policy: and the raw resource identifier otherwise. Well-known capability SIDs are resolved to their policy name; custom (hashed) capability SIDs that can't be reversed fall back to the `S-1-15-3-…` SID string. Named Section, - SymbolicLink, and Timer checks are verbose-only because the config has no - corresponding policy grants. Event 28 is - schema-discriminated: UI-shaped `Category`/`Detail` payloads emit `ui` - resources instead of treating the package SID as a capability. + SymbolicLink, Timer, and COM checks are verbose-only because the config has + no corresponding policy grants. Event 28 is schema-discriminated: UI-shaped + `Category`/`Detail` payloads emit `ui` resources instead of treating the + package SID as a capability. - `resourceType` is one of `file`, `ui`, `network`, `capability`, `other`; `accessType` is one of `read`, `write`, `execute`, `unknown`. Capability denials are recorded under `block`; current `allow` traces expose capability @@ -226,6 +226,39 @@ sandbox policy: - `filetime` is a decimal string containing the Windows `FILETIME` value, so JavaScript consumers retain all 64 bits without numeric precision loss. +### COM access checks + +On Windows builds with COM Learning Mode metadata, Kernel-General access-check +event 14 reports two additional object types: + +| `ObjectType` | Verbose reason | Identifier | +| ------------ | -------------- | ---------- | +| `ComActivationForClass` | `comActivation` | Activation CLSID | +| `ComCallOnInterface` | `comInterfaceCall` | Called interface IID | + +Both `captureDenials.mode: "block"` and `captureDenials.mode: "allow"` decode +these records identically. The mode still controls whether the denied operation +remains blocked or is permitted; it does not change the output classification. +MXC validates that the identifier is GUID-shaped. + +COM records do not enter the caller-facing `denials` array because MXC does not +expose an authorable COM grant. They are retained in the local verbose sibling +with `resourceType: "other"`, no `accessType`, and the original CLSID/IID in the +sanitized `ObjectName` property. The distinct reasons make COM activation and +interface calls recognizable without treating them as unknown object types or +policy-actionable denials. + +The `MXC.VerboseDenials` telemetry projection retains the COM reason and count +but removes all verbose properties, including the CLSID/IID. + +This coverage applies to classic COM activation checks instrumented in RPCSS and +COM interface-call checks instrumented in COMBASE. The separate WinRT +`CheckActivationPermissions` path does not currently attach this COM Learning +Mode metadata, so an MXC decoder-only change cannot identify those WinRT +activation denials as `ComActivationForClass`. `RPC Interface` events are +lower-layer LRPC diagnostics and remain `unsupportedObjectType`; they are not a +substitute for the COM permission event. + ### Verbose logging event signatures Every successful decode also writes a deterministic sibling file: @@ -235,7 +268,7 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ```json { - "version": 2, + "version": 3, "signatures": [ { "signature": { @@ -274,16 +307,19 @@ non-file resource values are retained. Complete file paths are replaced with Exact header timestamps and timestamp-like properties are omitted so otherwise identical events deduplicate, and free-form decoder errors are never serialized. -Every valid actionable denial is classified as `actionable` in the verbose -file, including its first occurrence, later duplicates, and candidates observed -after the actionable file's unique-denial bound. Those occurrences deduplicate -under the same signature and increment its count. `accessType` and +Every valid policy denial is classified as `actionable` in the verbose file. +Its first occurrence, later duplicates, and candidates observed after the +actionable file's unique-denial bound are all retained. Those occurrences +deduplicate under the same signature and increment its count. `accessType` and `resourceType` are included when denial extraction determined them; diagnostic outcomes without those classifications omit the fields. Candidates excluded from the actionable output retain a closed diagnostic reason and their sanitized event properties: +- `comActivation` and `comInterfaceCall` identify recognized classic COM + permission decisions. They include `resourceType: "other"` and omit + `accessType`; their CLSID/IID remains only in the local verbose properties. - `notActionable` includes registry writes, registry checks whose access mask cannot be classified as a read, and recognized Section, SymbolicLink, and Timer checks. MXC has no corresponding policy grants, so reporting them in @@ -297,7 +333,8 @@ reason and their sanitized event properties: - `unsupportedObjectType` means the event names a resource outside the supported diagnostic model. Examples include `\BaseNamedObjects` as a Directory, ALPC Ports such as - `ubpmtaskhostchannel`, and RPC Interface GUIDs. + `ubpmtaskhostchannel`, and lower-layer RPC Interface GUIDs. The explicit COM + object types documented above are supported and do not use this outcome. Property values longer than 256 characters retain bounded prefix and suffix context plus a SHA-256 digest of the complete sanitized value. This keeps long diff --git a/src/core/learning_mode_core/src/verbose_logging.rs b/src/core/learning_mode_core/src/verbose_logging.rs index a3870eac0..3fe8f964c 100644 --- a/src/core/learning_mode_core/src/verbose_logging.rs +++ b/src/core/learning_mode_core/src/verbose_logging.rs @@ -51,6 +51,10 @@ pub enum VerboseLoggingOutcomeReason { UnusableResourcePath, /// A capability event did not contain a usable capability denial. UnresolvedCapability, + /// A valid classic COM class-activation denial was retained for diagnostics. + ComActivation, + /// A valid classic COM interface-call denial was retained for diagnostics. + 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::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/core/learning_mode_platforms/windows/src/etl_decode.rs b/src/core/learning_mode_platforms/windows/src/etl_decode.rs index b3eb07d4b..802e13e08 100644 --- a/src/core/learning_mode_platforms/windows/src/etl_decode.rs +++ b/src/core/learning_mode_platforms/windows/src/etl_decode.rs @@ -1804,6 +1804,110 @@ 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"), + ], + ), + kernel_event( + 14, + 101, + 2, + &[ + ("Mode", "\"Permissive\""), + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", "\"{a47979d2-c419-11d9-a5b4-001185ad2b89}\""), + ("AccessMask", "0xffffffff"), + ], + ), + kernel_event( + 14, + 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)); + + 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.resource_type, Some(ResourceType::Other)); + assert!(call.signature.access_type.is_none()); + assert_eq!(property(&call.signature, "ObjectName"), call_iid); + } + + #[test] + fn malformed_com_identifier_remains_classified_verbose_diagnostic() { + let events = vec![kernel_event( + 14, + 42, + 1, + &[ + ("Mode", "\"Normal\""), + ("ObjectType", "\"ComActivationForClass\""), + ("ObjectName", "\"not-a-clsid\""), + ("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"), "not-a-clsid"); + } + #[test] fn unidentified_capability_events_are_omitted() { let events = vec![ diff --git a/src/core/learning_mode_platforms/windows/src/extractors.rs b/src/core/learning_mode_platforms/windows/src/extractors.rs index 8c7dbb5ef..054320cd3 100644 --- a/src/core/learning_mode_platforms/windows/src/extractors.rs +++ b/src/core/learning_mode_platforms/windows/src/extractors.rs @@ -33,9 +33,10 @@ //! classified registry reads are actionable; writes and unknown registry //! access are retained only as verbose diagnostics. Named Section, //! SymbolicLink, and Timer objects are likewise verbose-only because MXC has -//! no corresponding policy grants. -//! Other object types are dropped until their access-mask vocabulary is -//! understood. The [`AccessType`] is derived from the +//! no corresponding policy grants. COM activation and interface-call checks +//! are recognized as distinct verbose-only outcomes with their CLSID/IID +//! retained in sanitized properties. Other object types are dropped until +//! their access-mask vocabulary is understood. The [`AccessType`] is derived from the //! `AccessMask` field (see [`access_type_from_mask`]). Emitted under both //! learning modes (`block` → `Mode="Normal"`, `allow` → //! `Mode="Permissive"`). @@ -76,6 +77,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 +200,9 @@ pub(crate) fn verbose_logging_classification( ), Some(ResourceType::Other), ), + COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE => { + (None, Some(ResourceType::Other)) + } "" => (Some(AccessType::Unknown), Some(ResourceType::Capability)), _ => (None, None), } @@ -609,11 +615,13 @@ pub(crate) fn sanitize_properties(props: &[(String, String)]) -> Vec<(String, St /// /// The `ObjectType` field selects the resource type: `File` and `Key` /// (registry) map to concrete resources, an **empty** `ObjectType` is a -/// brokered-capability check, and the observed named-object types `Section`, -/// `SymbolicLink`, and `Timer` map to [`ResourceType::Other`]. Only registry -/// reads are actionable; other registry access and those named-object types are -/// excluded because MXC has no corresponding policy grants. Other object types -/// are dropped until their access-mask vocabulary is understood. An absent +/// brokered-capability check, the observed named-object types `Section`, +/// `SymbolicLink`, and `Timer` map to [`ResourceType::Other`], and COM +/// activation/interface checks are recognized as distinct verbose-only +/// outcomes with a CLSID/IID resource. Only registry reads are actionable; +/// other registry access, named-object types, and COM checks are excluded +/// because MXC has no corresponding policy grants. Other object types are +/// dropped until their access-mask vocabulary is understood. An absent /// `ObjectType` field drops the event. /// /// For file/registry resources the [`AccessType`] is derived from the @@ -633,7 +641,9 @@ pub(crate) fn sanitize_properties(props: &[(String, String)]) -> Vec<(String, St /// [`VerboseLoggingOutcomeReason::MissingObjectName`]; capability: an /// unidentified brokered check, /// [`VerboseLoggingOutcomeReason::UnresolvedCapability`] — [`crate::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 malformed COM +/// identifier ([`VerboseLoggingOutcomeReason::EventPayloadMalformed`]), a valid +/// COM check retained under its dedicated verbose reason, 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( @@ -652,6 +662,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, + COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE => ResourceType::Other, _ => return Err(VerboseLoggingOutcomeReason::UnsupportedObjectType), }; @@ -668,6 +679,19 @@ pub fn build_denial_from_access_check( (_, Some(name)) => name, }; + if is_com_object_type(object_type_str) && !is_guid_identifier(&object_name) { + return Err(VerboseLoggingOutcomeReason::EventPayloadMalformed); + } + match object_type_str { + COM_ACTIVATION_OBJECT_TYPE => { + return Err(VerboseLoggingOutcomeReason::ComActivation); + } + COM_CALL_OBJECT_TYPE => { + return Err(VerboseLoggingOutcomeReason::ComInterfaceCall); + } + _ => {} + } + if resource_type == ResourceType::File { let app_path = find_prop(&parts.props, "AppPath") .or_else(|| find_prop(&parts.props, "ApplicationPath")) @@ -678,9 +702,8 @@ pub fn build_denial_from_access_check( } let access_type = if resource_type == ResourceType::Capability { - // Capability checks report a mask (often 0x1) that is not a - // read/write/execute verb, so don't run the file/registry - // classifier over it. + // Capability checks report a mask whose vocabulary is not a + // file/registry read/write/execute verb. AccessType::Unknown } else { find_prop(&parts.props, "AccessMask") @@ -1002,6 +1025,30 @@ fn find_prop<'a>(props: &'a [(String, String)], name: &str) -> Option<&'a String props.iter().find(|(k, _)| k == name).map(|(_, v)| v) } +fn is_com_object_type(object_type: &str) -> bool { + matches!( + object_type, + COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE + ) +} + +fn is_guid_identifier(value: &str) -> bool { + let value = match (value.strip_prefix('{'), value.strip_suffix('}')) { + (Some(value), Some(_)) => &value[..value.len() - 1], + (None, None) => value, + _ => return false, + }; + + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1350,6 +1397,84 @@ 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, + ), + ] { + 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/core/mxc_engine/src/verbose_telemetry.rs b/src/core/mxc_engine/src/verbose_telemetry.rs index 9f15efac5..050e6dc47 100644 --- a/src/core/mxc_engine/src/verbose_telemetry.rs +++ b/src/core/mxc_engine/src/verbose_telemetry.rs @@ -215,13 +215,17 @@ mod tests { }; use wxc_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(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(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/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs index c9d0f0f57..db209d753 100644 --- a/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs +++ b/src/testing/wxc_e2e_tests/tests/e2e_telemetry_etw.rs @@ -474,7 +474,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"); From 65e09f5a4d0bd46de96090ccbce753a731490a5b Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Mon, 5 Oct 2026 15:59:38 -0700 Subject: [PATCH 2/7] Centralize COM diagnostic classification Copilot-Session: 7758b99e-b7a0-4060-baf9-268db306b58b --- .../windows/src/extractors.rs | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/core/learning_mode_platforms/windows/src/extractors.rs b/src/core/learning_mode_platforms/windows/src/extractors.rs index 054320cd3..d63161eb8 100644 --- a/src/core/learning_mode_platforms/windows/src/extractors.rs +++ b/src/core/learning_mode_platforms/windows/src/extractors.rs @@ -200,7 +200,7 @@ pub(crate) fn verbose_logging_classification( ), Some(ResourceType::Other), ), - COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE => { + object_type if com_outcome_reason(object_type).is_some() => { (None, Some(ResourceType::Other)) } "" => (Some(AccessType::Unknown), Some(ResourceType::Capability)), @@ -655,6 +655,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, @@ -662,7 +663,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, - COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE => ResourceType::Other, + _ if com_reason.is_some() => ResourceType::Other, _ => return Err(VerboseLoggingOutcomeReason::UnsupportedObjectType), }; @@ -679,17 +680,11 @@ pub fn build_denial_from_access_check( (_, Some(name)) => name, }; - if is_com_object_type(object_type_str) && !is_guid_identifier(&object_name) { - return Err(VerboseLoggingOutcomeReason::EventPayloadMalformed); - } - match object_type_str { - COM_ACTIVATION_OBJECT_TYPE => { - return Err(VerboseLoggingOutcomeReason::ComActivation); - } - COM_CALL_OBJECT_TYPE => { - return Err(VerboseLoggingOutcomeReason::ComInterfaceCall); + if let Some(reason) = com_reason { + if !is_guid_identifier(&object_name) { + return Err(VerboseLoggingOutcomeReason::EventPayloadMalformed); } - _ => {} + return Err(reason); } if resource_type == ResourceType::File { @@ -1025,11 +1020,12 @@ fn find_prop<'a>(props: &'a [(String, String)], name: &str) -> Option<&'a String props.iter().find(|(k, _)| k == name).map(|(_, v)| v) } -fn is_com_object_type(object_type: &str) -> bool { - matches!( - object_type, - COM_ACTIVATION_OBJECT_TYPE | COM_CALL_OBJECT_TYPE - ) +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 { From 815a08e4388d39e0f741e5ea79646101667cc12a Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Wed, 7 Oct 2026 09:16:33 -0700 Subject: [PATCH 3/7] Simplify COM identifier check and drop doc and comment changes Copilot-Session: ca558f28-d3df-4b85-9e44-b9fddef77c62 --- docs/learning-mode/capabilities.md | 57 ++++--------------- .../learning_mode_core/src/verbose_logging.rs | 4 +- .../windows/src/extractors.rs | 51 +++++++---------- 3 files changed, 33 insertions(+), 79 deletions(-) diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index d598dd066..1c406d233 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -214,10 +214,10 @@ sandbox policy: and the raw resource identifier otherwise. Well-known capability SIDs are resolved to their policy name; custom (hashed) capability SIDs that can't be reversed fall back to the `S-1-15-3-…` SID string. Named Section, - SymbolicLink, Timer, and COM checks are verbose-only because the config has - no corresponding policy grants. Event 28 is schema-discriminated: UI-shaped - `Category`/`Detail` payloads emit `ui` resources instead of treating the - package SID as a capability. + SymbolicLink, and Timer checks are verbose-only because the config has no + corresponding policy grants. Event 28 is + schema-discriminated: UI-shaped `Category`/`Detail` payloads emit `ui` + resources instead of treating the package SID as a capability. - `resourceType` is one of `file`, `ui`, `network`, `capability`, `other`; `accessType` is one of `read`, `write`, `execute`, `unknown`. Capability denials are recorded under `block`; current `allow` traces expose capability @@ -226,39 +226,6 @@ sandbox policy: - `filetime` is a decimal string containing the Windows `FILETIME` value, so JavaScript consumers retain all 64 bits without numeric precision loss. -### COM access checks - -On Windows builds with COM Learning Mode metadata, Kernel-General access-check -event 14 reports two additional object types: - -| `ObjectType` | Verbose reason | Identifier | -| ------------ | -------------- | ---------- | -| `ComActivationForClass` | `comActivation` | Activation CLSID | -| `ComCallOnInterface` | `comInterfaceCall` | Called interface IID | - -Both `captureDenials.mode: "block"` and `captureDenials.mode: "allow"` decode -these records identically. The mode still controls whether the denied operation -remains blocked or is permitted; it does not change the output classification. -MXC validates that the identifier is GUID-shaped. - -COM records do not enter the caller-facing `denials` array because MXC does not -expose an authorable COM grant. They are retained in the local verbose sibling -with `resourceType: "other"`, no `accessType`, and the original CLSID/IID in the -sanitized `ObjectName` property. The distinct reasons make COM activation and -interface calls recognizable without treating them as unknown object types or -policy-actionable denials. - -The `MXC.VerboseDenials` telemetry projection retains the COM reason and count -but removes all verbose properties, including the CLSID/IID. - -This coverage applies to classic COM activation checks instrumented in RPCSS and -COM interface-call checks instrumented in COMBASE. The separate WinRT -`CheckActivationPermissions` path does not currently attach this COM Learning -Mode metadata, so an MXC decoder-only change cannot identify those WinRT -activation denials as `ComActivationForClass`. `RPC Interface` events are -lower-layer LRPC diagnostics and remain `unsupportedObjectType`; they are not a -substitute for the COM permission event. - ### Verbose logging event signatures Every successful decode also writes a deterministic sibling file: @@ -268,7 +235,7 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ```json { - "version": 3, + "version": 2, "signatures": [ { "signature": { @@ -307,19 +274,16 @@ non-file resource values are retained. Complete file paths are replaced with Exact header timestamps and timestamp-like properties are omitted so otherwise identical events deduplicate, and free-form decoder errors are never serialized. -Every valid policy denial is classified as `actionable` in the verbose file. -Its first occurrence, later duplicates, and candidates observed after the -actionable file's unique-denial bound are all retained. Those occurrences -deduplicate under the same signature and increment its count. `accessType` and +Every valid actionable denial is classified as `actionable` in the verbose +file, including its first occurrence, later duplicates, and candidates observed +after the actionable file's unique-denial bound. Those occurrences deduplicate +under the same signature and increment its count. `accessType` and `resourceType` are included when denial extraction determined them; diagnostic outcomes without those classifications omit the fields. Candidates excluded from the actionable output retain a closed diagnostic reason and their sanitized event properties: -- `comActivation` and `comInterfaceCall` identify recognized classic COM - permission decisions. They include `resourceType: "other"` and omit - `accessType`; their CLSID/IID remains only in the local verbose properties. - `notActionable` includes registry writes, registry checks whose access mask cannot be classified as a read, and recognized Section, SymbolicLink, and Timer checks. MXC has no corresponding policy grants, so reporting them in @@ -333,8 +297,7 @@ reason and their sanitized event properties: - `unsupportedObjectType` means the event names a resource outside the supported diagnostic model. Examples include `\BaseNamedObjects` as a Directory, ALPC Ports such as - `ubpmtaskhostchannel`, and lower-layer RPC Interface GUIDs. The explicit COM - object types documented above are supported and do not use this outcome. + `ubpmtaskhostchannel`, and RPC Interface GUIDs. Property values longer than 256 characters retain bounded prefix and suffix context plus a SHA-256 digest of the complete sanitized value. This keeps long diff --git a/src/core/learning_mode_core/src/verbose_logging.rs b/src/core/learning_mode_core/src/verbose_logging.rs index 3fe8f964c..ac0f01def 100644 --- a/src/core/learning_mode_core/src/verbose_logging.rs +++ b/src/core/learning_mode_core/src/verbose_logging.rs @@ -51,9 +51,9 @@ pub enum VerboseLoggingOutcomeReason { UnusableResourcePath, /// A capability event did not contain a usable capability denial. UnresolvedCapability, - /// A valid classic COM class-activation denial was retained for diagnostics. + /// Classic COM class activation. ComActivation, - /// A valid classic COM interface-call denial was retained for diagnostics. + /// Classic COM interface call. ComInterfaceCall, /// The event was valid but did not describe an actionable denial. NotActionable, diff --git a/src/core/learning_mode_platforms/windows/src/extractors.rs b/src/core/learning_mode_platforms/windows/src/extractors.rs index d63161eb8..42e1359c7 100644 --- a/src/core/learning_mode_platforms/windows/src/extractors.rs +++ b/src/core/learning_mode_platforms/windows/src/extractors.rs @@ -33,10 +33,9 @@ //! classified registry reads are actionable; writes and unknown registry //! access are retained only as verbose diagnostics. Named Section, //! SymbolicLink, and Timer objects are likewise verbose-only because MXC has -//! no corresponding policy grants. COM activation and interface-call checks -//! are recognized as distinct verbose-only outcomes with their CLSID/IID -//! retained in sanitized properties. Other object types are dropped until -//! their access-mask vocabulary is understood. The [`AccessType`] is derived from the +//! no corresponding policy grants. +//! Other object types are dropped until their access-mask vocabulary is +//! understood. The [`AccessType`] is derived from the //! `AccessMask` field (see [`access_type_from_mask`]). Emitted under both //! learning modes (`block` → `Mode="Normal"`, `allow` → //! `Mode="Permissive"`). @@ -615,13 +614,11 @@ pub(crate) fn sanitize_properties(props: &[(String, String)]) -> Vec<(String, St /// /// The `ObjectType` field selects the resource type: `File` and `Key` /// (registry) map to concrete resources, an **empty** `ObjectType` is a -/// brokered-capability check, the observed named-object types `Section`, -/// `SymbolicLink`, and `Timer` map to [`ResourceType::Other`], and COM -/// activation/interface checks are recognized as distinct verbose-only -/// outcomes with a CLSID/IID resource. Only registry reads are actionable; -/// other registry access, named-object types, and COM checks are excluded -/// because MXC has no corresponding policy grants. Other object types are -/// dropped until their access-mask vocabulary is understood. An absent +/// brokered-capability check, and the observed named-object types `Section`, +/// `SymbolicLink`, and `Timer` map to [`ResourceType::Other`]. Only registry +/// reads are actionable; other registry access and those named-object types are +/// excluded because MXC has no corresponding policy grants. Other object types +/// are dropped until their access-mask vocabulary is understood. An absent /// `ObjectType` field drops the event. /// /// For file/registry resources the [`AccessType`] is derived from the @@ -641,9 +638,7 @@ pub(crate) fn sanitize_properties(props: &[(String, String)]) -> Vec<(String, St /// [`VerboseLoggingOutcomeReason::MissingObjectName`]; capability: an /// unidentified brokered check, /// [`VerboseLoggingOutcomeReason::UnresolvedCapability`] — [`crate::capability_dacl`] -/// may still recover it from the event's DACL payload), a malformed COM -/// identifier ([`VerboseLoggingOutcomeReason::EventPayloadMalformed`]), a valid -/// COM check retained under its dedicated verbose reason, or a self-access, +/// may still recover it from the event's DACL payload), 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( @@ -697,8 +692,9 @@ pub fn build_denial_from_access_check( } let access_type = if resource_type == ResourceType::Capability { - // Capability checks report a mask whose vocabulary is not a - // file/registry read/write/execute verb. + // Capability checks report a mask (often 0x1) that is not a + // read/write/execute verb, so don't run the file/registry + // classifier over it. AccessType::Unknown } else { find_prop(&parts.props, "AccessMask") @@ -1029,20 +1025,11 @@ fn com_outcome_reason(object_type: &str) -> Option } fn is_guid_identifier(value: &str) -> bool { - let value = match (value.strip_prefix('{'), value.strip_suffix('}')) { - (Some(value), Some(_)) => &value[..value.len() - 1], - (None, None) => value, - _ => return false, - }; - - value.len() == 36 - && value.bytes().enumerate().all(|(index, byte)| { - if matches!(index, 8 | 13 | 18 | 23) { - byte == b'-' - } else { - byte.is_ascii_hexdigit() - } - }) + let value = value + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + .unwrap_or(value); + GUID::try_from(value).is_ok() } #[cfg(test)] @@ -1440,6 +1427,10 @@ mod tests { Some("\"{00000132-0000-0000-C000-000000000046\""), VerboseLoggingOutcomeReason::EventPayloadMalformed, ), + ( + Some("\"00000132-0000-0000-C000-000000000046}\""), + VerboseLoggingOutcomeReason::EventPayloadMalformed, + ), ] { let mut properties = vec![("ObjectType", COM_CALL_OBJECT_TYPE)]; if let Some(object_name) = object_name { From 3442fbfa25558ae244d67e0d97bc192a9b4ca192 Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Wed, 7 Oct 2026 09:26:12 -0700 Subject: [PATCH 4/7] Document COM verbose outcomes and format v3 Copilot-Session: ca558f28-d3df-4b85-9e44-b9fddef77c62 --- docs/learning-mode/capabilities.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/learning-mode/capabilities.md b/docs/learning-mode/capabilities.md index 1c406d233..4647b2390 100644 --- a/docs/learning-mode/capabilities.md +++ b/docs/learning-mode/capabilities.md @@ -235,7 +235,7 @@ policy denial occurrences plus diagnostic outcomes omitted from the policy file: ```json { - "version": 2, + "version": 3, "signatures": [ { "signature": { @@ -294,6 +294,9 @@ 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` mean a classic COM class activation + or interface call was denied. The CLSID or IID is retained; a malformed + identifier is reported as `eventPayloadMalformed`. - `unsupportedObjectType` means the event names a resource outside the supported diagnostic model. Examples include `\BaseNamedObjects` as a Directory, ALPC Ports such as From 559fdc534350d67bafcabf68aac3a324fa9500fb Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Wed, 7 Oct 2026 09:42:09 -0700 Subject: [PATCH 5/7] Use consolidated module paths in COM tests Copilot-Session: ca558f28-d3df-4b85-9e44-b9fddef77c62 --- src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs | 2 +- src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs b/src/mxc-sdk/src/core/learning_mode_core/verbose_logging.rs index bdc39cfaa..6ee0945ff 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 @@ -489,7 +489,7 @@ mod tests { signature.event_id = u16::MAX; signature.reason = VerboseLoggingOutcomeReason::ComActivation; - signature.resource_type = Some(crate::ResourceType::Other); + signature.resource_type = Some(crate::learning_mode_core::ResourceType::Other); summary.record(signature); assert_eq!(summary.overflow_occurrences, 1); 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 8b6ce5add..6e9ae2608 100644 --- a/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs +++ b/src/mxc-sdk/src/core/mxc_engine/verbose_telemetry.rs @@ -360,7 +360,7 @@ mod tests { VerboseLoggingOutcomeReason::ComActivation, )]); document.signatures[0].signature.resource_type = - Some(learning_mode_core::ResourceType::Other); + Some(crate::learning_mode_core::ResourceType::Other); document = project_for_telemetry(document); @@ -368,7 +368,7 @@ mod tests { assert_eq!(signature.reason, VerboseLoggingOutcomeReason::ComActivation); assert_eq!( signature.resource_type, - Some(learning_mode_core::ResourceType::Other) + Some(crate::learning_mode_core::ResourceType::Other) ); assert!(signature.access_type.is_none()); assert!(signature.properties.is_empty()); From cf2459824e24ec966416a63c10ed4e14db230999 Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Wed, 7 Oct 2026 13:11:53 -0700 Subject: [PATCH 6/7] Clarify COM outcomes and cover privacy provider checks Copilot-Session: ca558f28-d3df-4b85-9e44-b9fddef77c62 --- docs/logging-access-denied.md | 9 +-- .../learning_mode_core/verbose_logging.rs | 2 +- .../core/learning_mode_windows/etl_decode.rs | 63 +++++++++++++++++-- .../core/learning_mode_windows/extractors.rs | 9 ++- 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/docs/logging-access-denied.md b/docs/logging-access-denied.md index b0c78b3a0..7b16eccf6 100644 --- a/docs/logging-access-denied.md +++ b/docs/logging-access-denied.md @@ -296,9 +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` mean a classic COM class activation - or interface call was denied. The CLSID or IID is retained; a malformed - identifier is reported as `eventPayloadMalformed`. +- `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 @@ -315,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 6ee0945ff..d0f28ff18 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, 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 0f54273f1..63b2ff326 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 @@ -1863,7 +1863,7 @@ mod tests { ("AccessMask", "0x1"), ], ), - kernel_event( + permissive_event( 14, 101, 2, @@ -1874,8 +1874,8 @@ mod tests { ("AccessMask", "0xffffffff"), ], ), - kernel_event( - 14, + permissive_event( + 4907, 102, 3, &[ @@ -1906,6 +1906,10 @@ mod tests { 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 @@ -1917,11 +1921,57 @@ mod tests { 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() { let events = vec![kernel_event( @@ -1931,7 +1981,7 @@ mod tests { &[ ("Mode", "\"Normal\""), ("ObjectType", "\"ComActivationForClass\""), - ("ObjectName", "\"not-a-clsid\""), + ("ObjectName", r"C:\Users\alice\secret.txt"), ("AccessMask", "0x1"), ], )]; @@ -1948,7 +1998,10 @@ mod tests { 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"), "not-a-clsid"); + assert_eq!( + property(signature, "ObjectName"), + crate::learning_mode_windows::extractors::REDACTED_PATH + ); } #[test] diff --git a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs index 81ea9e97c..1bf9d4759 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -638,7 +638,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( @@ -1433,6 +1436,10 @@ mod tests { 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 { From 3d5724851e063f810e9ea58ac5812462f62d277b Mon Sep 17 00:00:00 2001 From: Jacob Dereje Date: Wed, 7 Oct 2026 13:26:46 -0700 Subject: [PATCH 7/7] Redact invalid COM identifiers Copilot-Session: ca558f28-d3df-4b85-9e44-b9fddef77c62 --- .../core/learning_mode_windows/etl_decode.rs | 54 ++++++++++--------- .../core/learning_mode_windows/extractors.rs | 3 ++ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs b/src/mxc-sdk/src/core/learning_mode_windows/etl_decode.rs index 63b2ff326..a7df1aae0 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 @@ -1974,34 +1974,36 @@ mod tests { #[test] fn malformed_com_identifier_remains_classified_verbose_diagnostic() { - let events = vec![kernel_event( - 14, - 42, - 1, - &[ - ("Mode", "\"Normal\""), - ("ObjectType", "\"ComActivationForClass\""), - ("ObjectName", r"C:\Users\alice\secret.txt"), - ("AccessMask", "0x1"), - ], - )]; + 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); + 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 - ); + 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] 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 1bf9d4759..08ea72e26 100644 --- a/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs +++ b/src/mxc-sdk/src/core/learning_mode_windows/extractors.rs @@ -425,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; }