From 30d2bbd2327f6bb8ac16c4ab0a35574cd4ee3050 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 19:12:11 -0400 Subject: [PATCH 1/4] fix(sccm): repair client intake contracts (#319) --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 187 ++++++++++++++- .../src/sccm/client/intake.rs | 220 ++++++++++++------ .../client/intake/rotations/expected.json | 6 +- .../client/intake/rotations/manifest.json | 6 +- .../tests/sccm_client_intake.rs | 140 +++++++++-- .../tests/sccm_spine_contract.rs | 56 +++++ 6 files changed, 516 insertions(+), 99 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 370a4fee5..371ca6c9b 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -107,6 +107,143 @@ struct CatalogSpec { family: SccmArtifactFamily, } +/// Immutable client intake membership owned by the shared SCCM source +/// catalog. A physical source may feed more than one logical intake group; +/// `LocationServices.log` is intentionally captured once and projected into +/// both location and content coverage. +#[derive(Clone, Copy)] +pub(crate) struct SccmClientSourceMembership { + pub basename: &'static str, + pub logical_artifact_ids: &'static [&'static str], +} + +const CLIENT_SOURCE_MEMBERSHIPS: &[SccmClientSourceMembership] = &[ + SccmClientSourceMembership { + basename: "AppEnforce.log", + logical_artifact_ids: &["client-app-enforce"], + }, + SccmClientSourceMembership { + basename: "ExecMgr.log", + logical_artifact_ids: &["client-app-enforce"], + }, + SccmClientSourceMembership { + basename: "AppDiscovery.log", + logical_artifact_ids: &["client-app-intent"], + }, + SccmClientSourceMembership { + basename: "AppIntentEval.log", + logical_artifact_ids: &["client-app-intent"], + }, + SccmClientSourceMembership { + basename: "ccmsetup.log", + logical_artifact_ids: &["client-ccmsetup"], + }, + SccmClientSourceMembership { + basename: "client.msi.log", + logical_artifact_ids: &["client-ccmsetup"], + }, + SccmClientSourceMembership { + basename: "CAS.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "ContentTransferManager.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "DataTransferService.log", + logical_artifact_ids: &["client-content"], + }, + SccmClientSourceMembership { + basename: "CcmEval.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "CcmExec.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "CcmRestart.log", + logical_artifact_ids: &["client-evaluation"], + }, + SccmClientSourceMembership { + basename: "ClientIDManagerStartup.log", + logical_artifact_ids: &["client-identity"], + }, + SccmClientSourceMembership { + basename: "CcmMessaging.log", + logical_artifact_ids: &["client-location"], + }, + SccmClientSourceMembership { + basename: "ClientLocation.log", + logical_artifact_ids: &["client-location"], + }, + SccmClientSourceMembership { + basename: "LocationServices.log", + logical_artifact_ids: &["client-location", "client-content"], + }, + SccmClientSourceMembership { + basename: "PolicyAgent.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "PolicyAgentProvider.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "PolicyEvaluator.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "Scheduler.log", + logical_artifact_ids: &["client-policy-agent"], + }, + SccmClientSourceMembership { + basename: "CIAgent.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "CIDownloader.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "StateMessage.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "StatusAgent.log", + logical_artifact_ids: &["client-policy-state"], + }, + SccmClientSourceMembership { + basename: "ScanAgent.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesDeployment.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesHandler.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "UpdatesStore.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "WUAHandler.log", + logical_artifact_ids: &["client-updates"], + }, + SccmClientSourceMembership { + basename: "ReportingEvents.log", + logical_artifact_ids: &["client-windows-update-supplemental"], + }, +]; + +pub(crate) fn declared_client_source_memberships() -> &'static [SccmClientSourceMembership] { + CLIENT_SOURCE_MEMBERSHIPS +} + const SOURCE_CATALOG: &[CatalogSpec] = &[ CatalogSpec { basename: "CCMSetup", @@ -114,6 +251,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientSetup, }, + CatalogSpec { + basename: "client.msi", + logical_name: "clientMsi", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientSetup, + }, CatalogSpec { basename: "CcmEval", logical_name: "ccmEval", @@ -174,6 +317,30 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientPolicy, }, + CatalogSpec { + basename: "CIAgent", + logical_name: "ciAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "CIDownloader", + logical_name: "ciDownloader", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "StateMessage", + logical_name: "stateMessage", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, + CatalogSpec { + basename: "StatusAgent", + logical_name: "statusAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, CatalogSpec { basename: "Scheduler", logical_name: "scheduler", @@ -216,6 +383,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientApplication, }, + CatalogSpec { + basename: "ExecMgr", + logical_name: "execMgr", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, CatalogSpec { basename: "ScanAgent", logical_name: "scanAgent", @@ -246,6 +419,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientUpdates, }, + CatalogSpec { + basename: "ReportingEvents", + logical_name: "reportingEvents", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, CatalogSpec { basename: "smsts", logical_name: "smsts", @@ -411,7 +590,7 @@ pub fn classify_artifact_name(name: &str, role: SccmRole) -> SccmSourceCatalogEn role, family: entry.family.clone(), rotation: parsed.rotation, - uses_ccm_records: true, + uses_ccm_records: catalog_entry_uses_ccm_records(entry), supported_for_diagnosis: parsed.rotation_supported, }; } @@ -445,11 +624,15 @@ fn declared_catalog_entry(entry: &CatalogSpec, role: SccmRole) -> SccmSourceCata role, family: entry.family.clone(), rotation: SccmRotation::Current, - uses_ccm_records: true, + uses_ccm_records: catalog_entry_uses_ccm_records(entry), supported_for_diagnosis: true, } } +fn catalog_entry_uses_ccm_records(entry: &CatalogSpec) -> bool { + !matches!(entry.logical_name, "clientMsi" | "reportingEvents") +} + struct ParsedArtifactName<'a> { basename: &'a str, rotation: SccmRotation, diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 7a47d4caa..23a27dc7d 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -5,6 +5,9 @@ use chrono::DateTime; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::sccm::catalog::{ + classify_artifact_name, declared_client_source_memberships, SccmClientSourceMembership, +}; use crate::sccm::{ SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; @@ -156,6 +159,11 @@ pub struct SccmClientIntakeArtifact { /// every configured root for that source and therefore collides with any /// physical declaration for it. pub path_fingerprint: Option, + /// Versioned, privacy-safe identity shared by rotations of one physical + /// source. Optional for compatibility with pre-lineage intake values; + /// repeating a path fingerprint requires an explicit matching lineage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation_lineage: Option, pub relative_path: Option, pub fragment_complete: Option, } @@ -174,6 +182,8 @@ pub struct SccmClientIntakeFragment { pub rotation: SccmRotation, pub coverage: SccmCoverageState, pub path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation_lineage: Option, pub relative_path: Option, pub fragment_complete: Option, pub configmgr_version: Option, @@ -209,6 +219,8 @@ pub struct SccmClientUnsupportedArtifact { pub classification: SccmCoverageState, pub rotation: SccmRotation, pub path_fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rotation_lineage: Option, pub relative_path: Option, pub fragment_complete: Option, pub configmgr_version: Option, @@ -257,6 +269,8 @@ pub enum SccmClientIntakeError { DuplicateArtifactId, #[error("client intake contains an invalid path fingerprint")] InvalidPathFingerprint, + #[error("client intake contains an invalid rotation lineage")] + InvalidRotationLineage, #[error("client intake contains an invalid bundle-relative evidence path")] InvalidRelativePath, #[error("client intake contains a colliding path identity")] @@ -272,7 +286,6 @@ pub enum SccmClientIntakeError { #[derive(Clone, Copy)] struct ClientSourceGroupSpec { logical_artifact_id: &'static str, - accepted_basenames: &'static [&'static str], workflows: &'static [SccmClientWorkflow], requiredness: SccmClientSourceRequiredness, } @@ -287,92 +300,56 @@ const HEALTH_DEPLOYMENT: &[SccmClientWorkflow] = const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ ClientSourceGroupSpec { logical_artifact_id: "client-app-enforce", - accepted_basenames: &["AppEnforce.log", "ExecMgr.log"], workflows: DEPLOYMENT, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-app-intent", - accepted_basenames: &["AppDiscovery.log", "AppIntentEval.log"], workflows: DEPLOYMENT, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-ccmsetup", - accepted_basenames: &["ccmsetup.log", "client.msi.log"], workflows: HEALTH, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-content", - accepted_basenames: &[ - "CAS.log", - "ContentTransferManager.log", - "DataTransferService.log", - "LocationServices.log", - ], workflows: DEPLOYMENT, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-evaluation", - accepted_basenames: &["CcmEval.log", "CcmExec.log", "CcmRestart.log"], workflows: HEALTH, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-identity", - accepted_basenames: &["ClientIDManagerStartup.log"], workflows: HEALTH, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-location", - accepted_basenames: &[ - "CcmMessaging.log", - "ClientLocation.log", - "LocationServices.log", - ], workflows: HEALTH_DEPLOYMENT, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-policy-agent", - accepted_basenames: &[ - "PolicyAgent.log", - "PolicyAgentProvider.log", - "PolicyEvaluator.log", - "Scheduler.log", - ], workflows: POLICY, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-policy-state", - accepted_basenames: &[ - "CIAgent.log", - "CIDownloader.log", - "StateMessage.log", - "StatusAgent.log", - ], workflows: POLICY, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-updates", - accepted_basenames: &[ - "ScanAgent.log", - "UpdatesDeployment.log", - "UpdatesHandler.log", - "UpdatesStore.log", - "WUAHandler.log", - ], workflows: UPDATES, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { logical_artifact_id: "client-windows-update-supplemental", - accepted_basenames: &["ReportingEvents.log"], workflows: UPDATES, requiredness: SccmClientSourceRequiredness::Supplemental, }, @@ -383,10 +360,14 @@ pub fn declared_client_source_groups() -> Vec { .iter() .map(|group| SccmClientSourceGroupDefinition { logical_artifact_id: group.logical_artifact_id.to_owned(), - accepted_basenames: group - .accepted_basenames + accepted_basenames: declared_client_source_memberships() .iter() - .map(|basename| (*basename).to_owned()) + .filter(|source| { + source + .logical_artifact_ids + .contains(&group.logical_artifact_id) + }) + .map(|source| source.basename.to_owned()) .collect(), workflows: group.workflows.to_vec(), requiredness: group.requiredness, @@ -414,6 +395,7 @@ pub fn assess_client_intake( classification: SccmCoverageState::Unsupported, rotation: source.artifact.rotation.clone(), path_fingerprint: source.path_fingerprint.clone(), + rotation_lineage: source.rotation_lineage.clone(), relative_path: source.relative_path.clone(), fragment_complete: source.fragment_complete, configmgr_version: source.artifact.configmgr_version.clone(), @@ -460,8 +442,7 @@ pub fn assess_client_intake( }); } else { for fragment in &fragments { - if let Some(reason) = source_coverage_reason(&fragment.coverage, &fragment.basename) - { + if let Some(reason) = source_coverage_reason(fragment) { coverage_gaps.push(SccmClientIntakeCoverageGap { logical_artifact_id: definition.logical_artifact_id.to_owned(), artifact_id: Some(fragment.artifact_id.clone()), @@ -490,7 +471,9 @@ pub fn assess_client_intake( fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientIntakeError> { let mut artifact_ids = BTreeSet::new(); - let mut path_fingerprints = BTreeSet::new(); + let mut path_fingerprint_bindings: BTreeMap, String)> = BTreeMap::new(); + let mut rotation_lineage_bindings = BTreeMap::new(); + let mut lineage_rotation_identities = BTreeSet::new(); let mut relative_paths = BTreeSet::new(); // Canonical source identity (casefolded basename plus rotation // discriminator) for every declaration, split by declaration shape so @@ -547,13 +530,50 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta if !artifact_ids.insert(source.artifact.artifact_id.to_ascii_lowercase()) { return Err(SccmClientIntakeError::DuplicateArtifactId); } + if source + .rotation_lineage + .as_deref() + .is_some_and(|lineage| !is_safe_rotation_lineage(lineage)) + { + return Err(SccmClientIntakeError::InvalidRotationLineage); + } + + let basename = + source_basename_identity(&source.artifact.display_name, &source.artifact.rotation); + if let Some(lineage) = source.rotation_lineage.as_deref() { + if let Some(bound_basename) = rotation_lineage_bindings.get(lineage) { + if bound_basename != &basename { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + rotation_lineage_bindings.insert(lineage.to_owned(), basename.clone()); + } + if !lineage_rotation_identities.insert(( + lineage.to_owned(), + rotation_identity(&source.artifact.rotation), + )) { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } if let Some(fingerprint) = source.path_fingerprint.as_deref() { if !is_safe_path_identity(fingerprint) { return Err(SccmClientIntakeError::InvalidPathFingerprint); } - if !path_fingerprints.insert(fingerprint.to_ascii_lowercase()) { - return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + let fingerprint = fingerprint.to_ascii_lowercase(); + let lineage = source + .rotation_lineage + .as_ref() + .map(|value| value.to_owned()); + if let Some((bound_lineage, bound_basename)) = + path_fingerprint_bindings.get(&fingerprint) + { + if lineage.is_none() || bound_lineage != &lineage || bound_basename != &basename { + return Err(SccmClientIntakeError::CollidingPhysicalIdentity); + } + } else { + path_fingerprint_bindings + .insert(fingerprint.clone(), (lineage.clone(), basename.clone())); } } if let Some(relative_path) = source.relative_path.as_deref() { @@ -580,11 +600,6 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta if source.artifact.coverage == SccmCoverageState::Capped && fragment_complete { return Err(SccmClientIntakeError::InvalidFragmentCompleteness); } - // Captured means the whole source was copied; an admitted - // incomplete capture must be declared as Capped instead. - if source.artifact.coverage == SccmCoverageState::Captured && !fragment_complete { - return Err(SccmClientIntakeError::InvalidFragmentCompleteness); - } source .path_fingerprint .as_deref() @@ -609,8 +624,9 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta } if source.path_fingerprint.is_some() { // Pinned markers and physical captures under other configured - // roots are distinct sources. Reusing an exact fingerprint - // already failed the shared fingerprint dedup above. + // roots are distinct sources. Reusing a fingerprint without + // an explicit lineage, or reusing one lineage/rotation pair, + // already failed the identity checks above. if unpinned_marker_identities.contains(&source_identity) { return Err(SccmClientIntakeError::DuplicateArtifactId); } @@ -638,17 +654,44 @@ fn matching_groups( display_name: &str, rotation: &SccmRotation, ) -> Vec<&'static ClientSourceGroupSpec> { - CLIENT_SOURCE_GROUPS + let Some(source) = catalogued_client_source(display_name, rotation) else { + return Vec::new(); + }; + + source + .logical_artifact_ids .iter() - .filter(|group| { - group.accepted_basenames.iter().any(|basename| { - expected_rotated_name(basename, rotation) - .is_some_and(|expected| expected == display_name) - }) + .filter_map(|logical_artifact_id| { + CLIENT_SOURCE_GROUPS + .iter() + .find(|group| group.logical_artifact_id == *logical_artifact_id) }) .collect() } +fn catalogued_client_source( + display_name: &str, + rotation: &SccmRotation, +) -> Option<&'static SccmClientSourceMembership> { + let classified = classify_artifact_name(display_name, SccmRole::Client); + if !classified.supported_for_diagnosis || &classified.rotation != rotation { + return None; + } + + let source = declared_client_source_memberships() + .iter() + .find(|source| source.basename.eq_ignore_ascii_case(&classified.basename))?; + expected_rotated_name(source.basename, rotation) + .is_some_and(|expected| expected == display_name) + .then_some(source) +} + +fn source_basename_identity(display_name: &str, rotation: &SccmRotation) -> String { + catalogued_client_source(display_name, rotation) + .map(|source| source.basename.to_ascii_lowercase()) + .unwrap_or_else(|| display_name.to_ascii_lowercase()) +} + fn expected_rotated_name(basename: &str, rotation: &SccmRotation) -> Option { match rotation { SccmRotation::Current => Some(basename.to_owned()), @@ -668,6 +711,7 @@ fn intake_fragment(source: &SccmClientIntakeArtifact) -> SccmClientIntakeFragmen rotation: source.artifact.rotation.clone(), coverage: source.artifact.coverage.clone(), path_fingerprint: source.path_fingerprint.clone(), + rotation_lineage: source.rotation_lineage.clone(), relative_path: source.relative_path.clone(), fragment_complete: source.fragment_complete, configmgr_version: source.artifact.configmgr_version.clone(), @@ -716,28 +760,39 @@ fn coverage_reason(coverage: &SccmCoverageState) -> &'static str { } } -/// Per-source gap wording for every declaration without complete captured -/// coverage. The safe artifact ID on the gap disambiguates identical -/// basenames declared under separate configured roots. -fn source_coverage_reason(coverage: &SccmCoverageState, basename: &str) -> Option { - match coverage { +/// Per-source gap wording for every noncaptured declaration and for a +/// captured fragment that ends on an incomplete logical-record boundary. +/// The safe artifact ID disambiguates identical basenames declared under +/// separate configured roots. +fn source_coverage_reason(fragment: &SccmClientIntakeFragment) -> Option { + match &fragment.coverage { SccmCoverageState::Absent => Some(format!( - "No artifact for client source {basename} was supplied." + "No artifact for client source {} was supplied.", + fragment.basename + )), + SccmCoverageState::AccessDenied => Some(format!( + "Access was denied for client source {}.", + fragment.basename )), - SccmCoverageState::AccessDenied => { - Some(format!("Access was denied for client source {basename}.")) - } SccmCoverageState::Capped => Some(format!( - "Client source {basename} reached its capture limit." + "Client source {} reached its capture limit.", + fragment.basename )), SccmCoverageState::Skipped => Some(format!( - "Client source {basename} was intentionally skipped." + "Client source {} was intentionally skipped.", + fragment.basename )), SccmCoverageState::Unsupported => Some(format!( - "Client source {basename} was declared unsupported." + "Client source {} was declared unsupported.", + fragment.basename )), SccmCoverageState::ParseFailed => Some(format!( - "Client source {basename} could not be normalized completely." + "Client source {} could not be normalized completely.", + fragment.basename + )), + SccmCoverageState::Captured if fragment.fragment_complete == Some(false) => Some(format!( + "Client source {} was captured with an incomplete logical-record boundary.", + fragment.basename )), SccmCoverageState::Captured => None, } @@ -769,6 +824,12 @@ fn compare_fragments( right: &SccmClientIntakeFragment, ) -> Ordering { compare_rotation(&left.rotation, &right.rotation) + .then_with(|| { + left.rotation_lineage + .as_deref() + .unwrap_or_default() + .cmp(right.rotation_lineage.as_deref().unwrap_or_default()) + }) .then_with(|| { left.path_fingerprint .as_deref() @@ -847,9 +908,9 @@ fn is_safe_basename(value: &str, rotation: &SccmRotation) -> bool { } fn is_canonical_client_basename(value: &str) -> bool { - CLIENT_SOURCE_GROUPS + declared_client_source_memberships() .iter() - .any(|group| group.accepted_basenames.contains(&value)) + .any(|source| source.basename == value) } fn is_opaque_unsupported_basename(value: &str) -> bool { @@ -916,6 +977,15 @@ fn is_safe_path_identity(value: &str) -> bool { } } +fn is_safe_rotation_lineage(value: &str) -> bool { + value + .strip_prefix("synthetic:") + .is_some_and(is_safe_synthetic_fingerprint) + || value + .strip_prefix("cmtraceopen.lineage.sha256.v1:") + .is_some_and(is_sha256_digest) +} + fn is_safe_synthetic_fingerprint(payload: &str) -> bool { let tokens = payload.split([':', '-']).collect::>(); tokens.len() <= MAX_SYNTHETIC_FINGERPRINT_TOKENS @@ -975,8 +1045,8 @@ fn is_expected_client_bundle_group( [matching_group] => group == matching_group.logical_artifact_id, _ => { group == "client-location-services-shared" - && expected_rotated_name("LocationServices.log", rotation) - .is_some_and(|expected| expected == display_name) + && catalogued_client_source(display_name, rotation) + .is_some_and(|source| source.basename == "LocationServices.log") } } } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json index 008b072db..fefb0fae5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -2,12 +2,12 @@ "contractState": "proposedPending318", "scenario": "rotations", "workflowDiagnosisExpected": false, - "coverage": [{"logicalArtifactId":"client-app-enforce","state":"captured","fragmentCount":3,"rotationOrder":["current","lo","numbered:2"],"distinctPathFingerprints":3}], + "coverage": [{"logicalArtifactId":"client-app-enforce","state":"captured","fragmentCount":3,"rotationOrder":["current","lo","numbered:2"],"distinctPathFingerprints":1,"rotationLineages":["synthetic:app-enforce-root-a"]}], "artifactProvenance": [ {"artifactId":"fixture-rotations-app-enforce-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, {"artifactId":"fixture-rotations-app-enforce-root-a-lo","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-rotations-app-enforce-root-b-numbered-2","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"artifactId":"fixture-rotations-app-enforce-root-a-numbered-2","encoding":"utf-8","byteLimit":4096,"limitApplied":false} ], "requests": [], - "assertions": ["Each rotation fragment retains its physical artifact ID and path fingerprint.", "No phase, key, or finding is inferred from rotation grouping alone."] + "assertions": ["Each rotation fragment retains its physical artifact ID, shared source fingerprint, and rotation lineage.", "No phase, key, or finding is inferred from rotation grouping alone."] } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json index 8bab45780..b4505d00b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json @@ -4,8 +4,8 @@ "syntheticFixture": true, "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"LAB","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, "artifacts": [ - {"artifactId":"fixture-rotations-app-enforce-root-a-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic-root-a-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:02Z","bytesCopied":181,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, - {"artifactId":"fixture-rotations-app-enforce-root-a-lo","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.lo_","pathFingerprint":"synthetic-root-a-lo","rotation":{"kind":"lo","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:01Z","bytesCopied":176,"relativePath":"evidence/client-app-enforce/lo/AppEnforce.lo_"}, - {"artifactId":"fixture-rotations-app-enforce-root-b-numbered-2","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log.2","sanitizedSourcePath":"SYNTHETIC://root-b/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-b-numbered-2","rotation":{"kind":"numbered","number":2,"fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:00Z","bytesCopied":182,"relativePath":"evidence/client-app-enforce/numbered-2/AppEnforce.log.2"} + {"artifactId":"fixture-rotations-app-enforce-root-a-current","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log","pathFingerprint":"synthetic-root-a","rotation":{"kind":"current","lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:02Z","bytesCopied":181,"relativePath":"evidence/client-app-enforce/current/AppEnforce.log"}, + {"artifactId":"fixture-rotations-app-enforce-root-a-lo","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.lo_","pathFingerprint":"synthetic-root-a","rotation":{"kind":"lo","lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:01Z","bytesCopied":176,"relativePath":"evidence/client-app-enforce/lo/AppEnforce.lo_"}, + {"artifactId":"fixture-rotations-app-enforce-root-a-numbered-2","designOnlyCatalog":{"entryId":"client-app-enforce","groupMemberships":["client-app-enforce"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"AppEnforce.log.2","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-a","rotation":{"kind":"numbered","number":2,"lineageId":"synthetic:app-enforce-root-a","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:00Z","bytesCopied":182,"relativePath":"evidence/client-app-enforce/numbered-2/AppEnforce.log.2"} ] } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 3d60b4720..f826de94d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -3,9 +3,9 @@ use std::fs; use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{ - assess_client_intake, declared_client_source_groups, SccmArtifact, SccmClientIntakeArtifact, - SccmClientIntakeBundle, SccmClientIntakeError, SccmCoverageState, SccmRole, SccmRotation, - SccmUnknownRotation, + assess_client_intake, classify_artifact_name, declared_client_source_groups, SccmArtifact, + SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeError, SccmCoverageState, + SccmRole, SccmRotation, SccmUnknownRotation, }; use serde::Deserialize; @@ -38,6 +38,7 @@ struct FixtureRotation { kind: String, number: Option, timestamp: Option, + lineage_id: Option, fragment_complete: Option, } @@ -73,6 +74,7 @@ fn load_bundle(scenario: &str) -> SccmClientIntakeBundle { encoding: fixture.encoding, }, path_fingerprint: fixture.path_fingerprint, + rotation_lineage: fixture.rotation.lineage_id, relative_path: fixture.relative_path, fragment_complete: fixture.rotation.fragment_complete, } @@ -136,6 +138,7 @@ fn synthetic_artifact(artifact_id: &str, display_name: &str) -> SccmClientIntake encoding: Some("utf-8".to_owned()), }, path_fingerprint: Some(format!("synthetic-{artifact_id}")), + rotation_lineage: None, relative_path: Some(relative_path), fragment_complete: Some(true), } @@ -198,6 +201,25 @@ fn serialized_privacy_probe_detects_forward_slash_normalized_windows_user_path() ); } +#[test] +fn every_declared_client_basename_is_supported_by_the_authoritative_catalog() { + for group in declared_client_source_groups() { + for basename in group.accepted_basenames { + let classified = classify_artifact_name(&basename, SccmRole::Client); + assert!( + classified.supported_for_diagnosis, + "{basename} in {} bypasses the shared SCCM catalog", + group.logical_artifact_id + ); + assert_eq!( + classified.uses_ccm_records, + !matches!(basename.as_str(), "client.msi.log" | "ReportingEvents.log"), + "the shared catalog must not route a non-CCM supplement through raw CCM" + ); + } + } +} + #[test] fn complete_client_intake_covers_every_declared_group_without_a_diagnosis() { let declared = declared_client_source_groups(); @@ -254,7 +276,17 @@ fn rotations_are_one_group_with_stable_physical_order_and_reordering_is_determin .filter_map(|fragment| fragment.path_fingerprint.as_deref()) .collect::>() .len(), - 3 + 1, + "one configured source fingerprint is retained across its rotations" + ); + assert_eq!( + group + .fragments + .iter() + .filter_map(|fragment| fragment.rotation_lineage.as_deref()) + .collect::>(), + BTreeSet::from(["synthetic:app-enforce-root-a"]), + "every rotation retains the immutable source lineage" ); let mut reordered = bundle; @@ -264,6 +296,31 @@ fn rotations_are_one_group_with_stable_physical_order_and_reordering_is_determin serde_json::to_string(&reordered).expect("reordered JSON"), serde_json::to_string(&intake).expect("intake JSON") ); + + let mut duplicate_bundle = load_bundle("rotations"); + let mut duplicate = duplicate_bundle.artifacts[1].clone(); + duplicate.artifact.artifact_id = "fixture-rotations-app-enforce-root-a-lo-two".to_owned(); + duplicate.relative_path = + Some("evidence/client-app-enforce/root-a/lo/AppEnforce.lo_".to_owned()); + duplicate_bundle.artifacts.push(duplicate); + assert_eq!( + assess_client_intake(&duplicate_bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one lineage cannot declare the same physical rotation twice" + ); + + let mut conflicting_root_bundle = load_bundle("rotations"); + let mut conflicting_root = conflicting_root_bundle.artifacts[1].clone(); + conflicting_root.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-lo".to_owned(); + conflicting_root.path_fingerprint = Some("synthetic-root-b".to_owned()); + conflicting_root.relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + conflicting_root_bundle.artifacts.push(conflicting_root); + assert_eq!( + assess_client_intake(&conflicting_root_bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one lineage and rotation cannot be relabeled as a second configured root" + ); } #[test] @@ -338,6 +395,7 @@ fn capped_cas_fragment_cannot_claim_complete() { encoding: Some("utf-8".to_owned()), }, path_fingerprint: Some("synthetic:content-capped".to_owned()), + rotation_lineage: None, relative_path: Some("evidence/client-content/current/CAS.log".to_owned()), fragment_complete: Some(true), }; @@ -352,19 +410,27 @@ fn capped_cas_fragment_cannot_claim_complete() { } #[test] -fn captured_cas_fragment_cannot_admit_an_incomplete_capture() { - let mut contradictory = synthetic_artifact("content-a", "CAS.log"); - contradictory.relative_path = Some("evidence/client-content/current/CAS.log".to_owned()); - contradictory.fragment_complete = Some(false); +fn captured_incomplete_fragment_retains_a_boundary_without_becoming_capped() { + let mut boundary = synthetic_artifact("content-a", "CAS.log"); + boundary.relative_path = Some("evidence/client-content/current/CAS.log".to_owned()); + boundary.fragment_complete = Some(false); - assert_eq!( - assess_client_intake(&SccmClientIntakeBundle { - artifacts: vec![contradictory], - }), - Err(SccmClientIntakeError::InvalidFragmentCompleteness), - "a captured physical fragment admitting an incomplete capture must fail closed; \ - Capped exists to represent an incomplete capture" - ); + let intake = assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![boundary], + }) + .expect("a fully copied rotation may still end on an incomplete logical record"); + let content = intake.group("client-content").expect("content group"); + + assert_eq!(content.coverage, SccmCoverageState::Captured); + assert_eq!(content.fragments[0].coverage, SccmCoverageState::Captured); + assert_eq!(content.fragments[0].fragment_complete, Some(false)); + assert!(intake.coverage_gaps.iter().any(|gap| { + gap.logical_artifact_id == "client-content" + && gap.artifact_id.as_deref() == Some("fixture-content-a") + && gap.coverage == SccmCoverageState::Captured + && gap.reason + == "Client source CAS.log was captured with an incomplete logical-record boundary." + })); } #[test] @@ -1387,6 +1453,48 @@ fn identity_bearing_relative_paths_fail_before_public_projection() { ); } +#[test] +fn rotation_lineage_is_versioned_privacy_safe_and_bound_to_one_source() { + let digest = "a".repeat(64); + let mut opaque = synthetic_artifact("policy-a", "PolicyAgent.log"); + opaque.rotation_lineage = Some(format!("cmtraceopen.lineage.sha256.v1:{digest}")); + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![opaque], + }) + .expect("the versioned opaque lineage form is accepted"); + + for lineage in [ + "", + "synthetic:free-form-user-value", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "cmtraceopen.lineage.sha256.v1:short", + "cmtraceopen.lineage.sha256.v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + r"C:\Users\RealUser\CCM\Logs", + ] { + let mut artifact = synthetic_artifact("policy-a", "PolicyAgent.log"); + artifact.rotation_lineage = Some(lineage.to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![artifact], + }), + Err(SccmClientIntakeError::InvalidRotationLineage), + "unsafe lineage reached the public projection: {lineage:?}" + ); + } + + let mut policy = synthetic_artifact("policy-a", "PolicyAgent.log"); + policy.rotation_lineage = Some("synthetic:policy-root-a".to_owned()); + let mut state = synthetic_artifact("state-b", "CIAgent.log"); + state.rotation_lineage = Some("synthetic:policy-root-a".to_owned()); + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![policy, state], + }), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one immutable lineage cannot be rebound to a different catalog source" + ); +} + #[test] fn shared_location_services_path_binding_preserves_every_canonical_rotation() { let rotations = [ diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index d8b6f7cef..a84504083 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -5581,6 +5581,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "client.msi.log", + SccmRole::Client, + "clientMsi", + SccmArtifactFamily::ClientSetup, + false, + true, + ), ( "CcmEval.log", SccmRole::Client, @@ -5661,6 +5669,38 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "CIAgent.log", + SccmRole::Client, + "ciAgent", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "CIDownloader.log", + SccmRole::Client, + "ciDownloader", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "StateMessage.log", + SccmRole::Client, + "stateMessage", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), + ( + "StatusAgent.log", + SccmRole::Client, + "statusAgent", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), ( "Scheduler.log", SccmRole::Client, @@ -5717,6 +5757,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "ExecMgr.log", + SccmRole::Client, + "execMgr", + SccmArtifactFamily::ClientApplication, + true, + true, + ), ( "ScanAgent.log", SccmRole::Client, @@ -5757,6 +5805,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "ReportingEvents.log", + SccmRole::Client, + "reportingEvents", + SccmArtifactFamily::ClientUpdates, + false, + true, + ), ( "smsts.log", SccmRole::Client, From 9e4d4100b97a36fdc3bc301d156b170e32de1ffb Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 19:12:22 -0400 Subject: [PATCH 2/4] fix(sccm): bind multi-dot catalog requests (#319) --- .../cmtraceopen-parser/src/sccm/findings.rs | 29 +++- .../tests/sccm_spine_contract.rs | 133 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 3e4fb3284..2861f8338 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1952,7 +1952,8 @@ fn requested_artifact_identity_ranges( requested_basename: &str, requested_logical_id: &str, ) -> Vec<(usize, usize)> { - let basename = normalize_catalog_identity(catalog_log_stem(requested_basename)); + let basename_stem = catalog_log_stem(requested_basename); + let basename = normalize_catalog_identity(basename_stem); let logical_id = normalize_catalog_identity(requested_logical_id); let mut ranges = tokens .iter() @@ -1961,11 +1962,37 @@ fn requested_artifact_identity_ranges( .map(|(index, _)| (index, index + 1)) .collect::>(); + // Exact request authorization is established earlier from the selected + // catalog entry and its punctuated alias. This additional range only lets + // the broad-scope guard recognize that same basename after tokenization + // splits a catalog identity such as `client.msi` at its punctuation. + let basename_components = basename_stem + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .filter(|component| !component.is_empty()) + .map(normalize_catalog_identity) + .collect::>(); + if basename_components.len() > 1 { + ranges.extend( + tokens + .windows(basename_components.len()) + .enumerate() + .filter_map(|(index, window)| { + window + .iter() + .zip(&basename_components) + .all(|(token, component)| *token == component) + .then_some((index, index + window.len())) + }), + ); + } + if logical_id == "smsts" { ranges.extend(tokens.windows(3).enumerate().filter_map(|(index, window)| { (window == ["task", "sequence", "log"]).then_some((index, index + 3)) })); } + ranges.sort_unstable(); + ranges.dedup(); ranges } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index a84504083..bdb6d7e72 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -2564,6 +2564,139 @@ fn finding_review_every_exact_catalog_identity_passes_at_every_public_boundary() ); } +#[test] +fn finding_request_accepts_exact_multi_dot_catalog_basename_at_every_public_boundary() { + let reason = "Collect the complete client.msi.log file."; + let request = finding_request("clientMsi", SccmRole::Client, reason); + let canonical = finding_with_gap_and_request("exact-multi-dot-catalog-identity-parity"); + let mut rejected = Vec::new(); + + let builder = SccmFindingBuilder::new("exact-multi-dot-catalog-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(request.clone()) + .build(); + if let Err(error) = builder { + rejected.push(format!("builder ({error:?})")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = request.clone(); + if let Err(error) = direct.validate() { + rejected.push(format!("direct validate ({error:?})")); + } + if let Err(error) = serde_json::to_value(&direct) { + rejected.push(format!("serializer ({error})")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::to_value(request).unwrap(); + if let Err(error) = serde_json::from_value::(json) { + rejected.push(format!("deserializer ({error})")); + } + + assert!( + rejected.is_empty(), + "rejected exact multi-dot catalog identity: {rejected:#?}" + ); +} + +#[test] +fn finding_request_rejects_multi_dot_scope_without_exact_catalog_authorization() { + let canonical = finding_with_gap_and_request("invalid-multi-dot-catalog-identity-parity"); + let cases = [ + ( + "unknown multi-dot basename", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect the complete client.unknown.log file.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "mismatched logical id", + finding_request( + "policyAgent", + SccmRole::Client, + "Collect the complete client.msi.log file.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "mismatched role", + finding_request( + "clientMsi", + SccmRole::ManagementPoint, + "Collect the complete client.msi.log file.", + ), + SccmFindingValidationError::ArtifactRequestRoleMismatch, + ), + ( + "glob", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect client.msi*.log from the bundle.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ( + "unbounded language", + finding_request( + "clientMsi", + SccmRole::Client, + "Collect client.msi.log and every file on the system.", + ), + SccmFindingValidationError::InvalidArtifactRequestReason, + ), + ]; + let mut incorrectly_accepted = Vec::new(); + + for (label, request, expected_error) in cases { + let builder = SccmFindingBuilder::new("invalid-multi-dot-catalog-identity-builder") + .class(SccmFindingClass::Symptom) + .phase(SccmPhase::Policy) + .role(SccmRole::Client) + .severity(Severity::Warning) + .confidence(SccmConfidence::Low) + .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) + .next_artifact(request.clone()) + .build(); + if builder.err() != Some(expected_error) { + incorrectly_accepted.push(format!("builder: {label}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0] = request.clone(); + if direct.validate().err() != Some(expected_error) { + incorrectly_accepted.push(format!("direct validate: {label}")); + } + if serde_json::to_value(&direct).is_ok() { + incorrectly_accepted.push(format!("serializer: {label}")); + } + + let mut json = serde_json::to_value(&canonical).unwrap(); + json["nextArtifacts"][0] = serde_json::to_value(request).unwrap(); + let deserialized = serde_json::from_value::(json); + let matches_expected = deserialized + .err() + .is_some_and(|error| error.to_string().contains(&format!("{expected_error:?}"))); + if !matches_expected { + incorrectly_accepted.push(format!("deserializer: {label}")); + } + } + + assert!( + incorrectly_accepted.is_empty(), + "accepted or misclassified unauthorized multi-dot requests: {incorrectly_accepted:#?}" + ); +} + #[test] fn finding_review_percentages_are_not_environment_paths_at_every_public_boundary() { let reason = "Collect PolicyAgent.log after 50% and before 60% completion."; From eae25a8f16f88fd253cd05ea78a38b74802f74e8 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 19:19:58 -0400 Subject: [PATCH 3/4] fix(sccm): bind and order client source lineages (#319) --- .../src/sccm/client/intake.rs | 37 ++++++---- .../tests/sccm_client_intake.rs | 71 +++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 23a27dc7d..7e66c803f 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -538,15 +538,28 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta return Err(SccmClientIntakeError::InvalidRotationLineage); } + let path_fingerprint = match source.path_fingerprint.as_deref() { + Some(fingerprint) if !is_safe_path_identity(fingerprint) => { + return Err(SccmClientIntakeError::InvalidPathFingerprint); + } + Some(fingerprint) => Some(fingerprint.to_ascii_lowercase()), + None => None, + }; + let basename = source_basename_identity(&source.artifact.display_name, &source.artifact.rotation); if let Some(lineage) = source.rotation_lineage.as_deref() { - if let Some(bound_basename) = rotation_lineage_bindings.get(lineage) { - if bound_basename != &basename { + if let Some((bound_basename, bound_fingerprint)) = + rotation_lineage_bindings.get(lineage) + { + if bound_basename != &basename || bound_fingerprint != &path_fingerprint { return Err(SccmClientIntakeError::CollidingPhysicalIdentity); } } else { - rotation_lineage_bindings.insert(lineage.to_owned(), basename.clone()); + rotation_lineage_bindings.insert( + lineage.to_owned(), + (basename.clone(), path_fingerprint.clone()), + ); } if !lineage_rotation_identities.insert(( lineage.to_owned(), @@ -556,11 +569,7 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta } } - if let Some(fingerprint) = source.path_fingerprint.as_deref() { - if !is_safe_path_identity(fingerprint) { - return Err(SccmClientIntakeError::InvalidPathFingerprint); - } - let fingerprint = fingerprint.to_ascii_lowercase(); + if let Some(fingerprint) = path_fingerprint { let lineage = source .rotation_lineage .as_ref() @@ -823,19 +832,17 @@ fn compare_fragments( left: &SccmClientIntakeFragment, right: &SccmClientIntakeFragment, ) -> Ordering { - compare_rotation(&left.rotation, &right.rotation) + left.path_fingerprint + .as_deref() + .unwrap_or_default() + .cmp(right.path_fingerprint.as_deref().unwrap_or_default()) .then_with(|| { left.rotation_lineage .as_deref() .unwrap_or_default() .cmp(right.rotation_lineage.as_deref().unwrap_or_default()) }) - .then_with(|| { - left.path_fingerprint - .as_deref() - .unwrap_or_default() - .cmp(right.path_fingerprint.as_deref().unwrap_or_default()) - }) + .then_with(|| compare_rotation(&left.rotation, &right.rotation)) .then_with(|| left.basename.cmp(&right.basename)) .then_with(|| left.artifact_id.cmp(&right.artifact_id)) } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index f826de94d..5aba66c8a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -323,6 +323,77 @@ fn rotations_are_one_group_with_stable_physical_order_and_reordering_is_determin ); } +#[test] +fn rotation_lineage_cannot_cross_path_fingerprints_across_distinct_rotations() { + let mut bundle = load_bundle("rotations"); + bundle.artifacts[1].path_fingerprint = Some("synthetic-root-b".to_owned()); + bundle.artifacts[1].relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + + assert_eq!( + assess_client_intake(&bundle), + Err(SccmClientIntakeError::CollidingPhysicalIdentity), + "one immutable lineage cannot combine rotations from distinct configured roots" + ); +} + +#[test] +fn fragment_order_is_source_identity_then_rotation_rank() { + let fixture = load_bundle("rotations"); + let mut root_a_current = fixture.artifacts[0].clone(); + root_a_current.relative_path = + Some("evidence/client-app-enforce/root-a/current/AppEnforce.log".to_owned()); + let mut root_a_lo = fixture.artifacts[1].clone(); + root_a_lo.relative_path = + Some("evidence/client-app-enforce/root-a/lo/AppEnforce.lo_".to_owned()); + + let mut root_b_current = root_a_current.clone(); + root_b_current.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-current".to_owned(); + root_b_current.path_fingerprint = Some("synthetic-root-b".to_owned()); + root_b_current.rotation_lineage = Some("synthetic:app-enforce-root-b".to_owned()); + root_b_current.relative_path = + Some("evidence/client-app-enforce/root-b/current/AppEnforce.log".to_owned()); + + let mut root_b_lo = root_a_lo.clone(); + root_b_lo.artifact.artifact_id = "fixture-rotations-app-enforce-root-b-lo".to_owned(); + root_b_lo.path_fingerprint = Some("synthetic-root-b".to_owned()); + root_b_lo.rotation_lineage = Some("synthetic:app-enforce-root-b".to_owned()); + root_b_lo.relative_path = + Some("evidence/client-app-enforce/root-b/lo/AppEnforce.lo_".to_owned()); + + let bundle = SccmClientIntakeBundle { + artifacts: vec![root_b_lo, root_a_current, root_b_current, root_a_lo], + }; + let assessment = assess_client_intake(&bundle).expect("two source lineages are valid"); + let ordered_ids = assessment + .group("client-app-enforce") + .expect("app enforcement group") + .fragments + .iter() + .map(|fragment| fragment.artifact_id.as_str()) + .collect::>(); + + assert_eq!( + ordered_ids, + [ + "fixture-rotations-app-enforce-root-a-current", + "fixture-rotations-app-enforce-root-a-lo", + "fixture-rotations-app-enforce-root-b-current", + "fixture-rotations-app-enforce-root-b-lo", + ], + "stable source/path identity must precede rotation rank" + ); + + let mut reordered = bundle; + reordered.artifacts.reverse(); + assert_eq!( + serde_json::to_string(&assess_client_intake(&reordered).expect("reordered intake")) + .expect("reordered JSON"), + serde_json::to_string(&assessment).expect("assessment JSON"), + "source-first ordering must remain independent of declaration order" + ); +} + #[test] fn missing_access_denied_and_capped_sources_remain_exact_coverage_states() { let missing = assessment("missing-root"); From 2b68537c7928e9af27f6c5ecda5c362f217d3da7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 19:26:27 -0400 Subject: [PATCH 4/4] fix(sccm): align client setup catalog identity (#319) --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 2 +- crates/cmtraceopen-parser/tests/sccm_client_intake.rs | 5 +++++ crates/cmtraceopen-parser/tests/sccm_spine_contract.rs | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 371ca6c9b..127df01f5 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -246,7 +246,7 @@ pub(crate) fn declared_client_source_memberships() -> &'static [SccmClientSource const SOURCE_CATALOG: &[CatalogSpec] = &[ CatalogSpec { - basename: "CCMSetup", + basename: "ccmsetup", logical_name: "ccmSetup", role: SccmRole::Client, family: SccmArtifactFamily::ClientSetup, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 5aba66c8a..31debb132 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -211,6 +211,11 @@ fn every_declared_client_basename_is_supported_by_the_authoritative_catalog() { "{basename} in {} bypasses the shared SCCM catalog", group.logical_artifact_id ); + assert_eq!( + classified.basename, basename, + "{basename} in {} must use the shared catalog's exact canonical basename", + group.logical_artifact_id + ); assert_eq!( classified.uses_ccm_records, !matches!(basename.as_str(), "client.msi.log" | "ReportingEvents.log"), diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index bdb6d7e72..2f8e8445e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -5707,7 +5707,7 @@ type ExpectedCatalogTuple = ( fn expected_catalog_tuples() -> Vec { vec![ ( - "CCMSetup.log", + "ccmsetup.log", SccmRole::Client, "ccmSetup", SccmArtifactFamily::ClientSetup,