From 69dccbf63785b7601a34a3d3b7769e7eea1f363e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:31:01 -0400 Subject: [PATCH 1/6] refactor(parser): promote the raw-preserving enum macro to a shared module Before: `raw_preserving_string_enum!` lived as a private `macro_rules!` at the top of `esp/models.rs`. It is the crate's one mechanism for round-tripping unrecognized wire values losslessly, but any other schema module that wanted it had to either make `esp` internals public or hand-copy the macro body. This moves the macro to a new crate-internal `src/wire.rs` and has `esp/models.rs` import it via `pub(crate) use`. The expansion now names serde through absolute paths (`::serde::Serialize`, `::core::result::Result`), so a caller no longer has to arrange for the right traits to be in scope. This is the right seam because tolerance to unknown wire values is a crate-wide property of public capture schemas, not an ESP detail. Keeping exactly one copy means a future fix to the round-trip behavior lands everywhere at once. Behavior is unchanged: the generated code is identical, and the `esp` public API is untouched. Verified with `cargo check --locked --all-targets` (exit 0) and `cargo clippy --locked --all-targets -- -D warnings` (exit 0). Co-Authored-By: Claude Opus 5 --- crates/cmtraceopen-parser/src/esp/models.rs | 47 ++-------------- crates/cmtraceopen-parser/src/lib.rs | 1 + crates/cmtraceopen-parser/src/wire.rs | 59 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 44 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/wire.rs diff --git a/crates/cmtraceopen-parser/src/esp/models.rs b/crates/cmtraceopen-parser/src/esp/models.rs index a165e69c0..7e90be478 100644 --- a/crates/cmtraceopen-parser/src/esp/models.rs +++ b/crates/cmtraceopen-parser/src/esp/models.rs @@ -1,47 +1,6 @@ -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -macro_rules! raw_preserving_string_enum { - ( - $(#[$meta:meta])* - pub enum $name:ident { - $($variant:ident => $wire_value:literal),+ $(,)? - } - ) => { - $(#[$meta])* - #[derive(Debug, Clone, PartialEq, Eq)] - pub enum $name { - $($variant,)+ - Unknown(String), - } - - impl Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let value = match self { - $(Self::$variant => $wire_value,)+ - Self::Unknown(raw) => raw.as_str(), - }; - - serializer.serialize_str(value) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let raw = String::deserialize(deserializer)?; - Ok(match raw.as_str() { - $($wire_value => Self::$variant,)+ - _ => Self::Unknown(raw), - }) - } - } - }; -} +use serde::{Deserialize, Serialize}; + +use crate::wire::raw_preserving_string_enum; pub const ESP_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1; diff --git a/crates/cmtraceopen-parser/src/lib.rs b/crates/cmtraceopen-parser/src/lib.rs index e687bc703..280fc4244 100644 --- a/crates/cmtraceopen-parser/src/lib.rs +++ b/crates/cmtraceopen-parser/src/lib.rs @@ -14,3 +14,4 @@ pub mod esp; pub mod intune; pub mod models; pub mod parser; +pub(crate) mod wire; diff --git a/crates/cmtraceopen-parser/src/wire.rs b/crates/cmtraceopen-parser/src/wire.rs new file mode 100644 index 000000000..21ddce82f --- /dev/null +++ b/crates/cmtraceopen-parser/src/wire.rs @@ -0,0 +1,59 @@ +//! Crate-internal helpers for defining tolerant wire-format types. +//! +//! Public capture schemas in this crate have to survive adapters that emit +//! values we have never seen. The helpers here are the single source of that +//! tolerance so every schema module behaves identically. + +/// Define a string enum that preserves unrecognized wire values losslessly. +/// +/// The generated enum gains an `Unknown(String)` variant. Deserialization maps +/// any unrecognized string into `Unknown`, and serialization writes the raw +/// string back out unchanged, so a round trip never silently drops or rewrites +/// a value produced by a newer adapter. +/// +/// The expansion refers to `serde` through absolute paths, so a caller only +/// needs the macro itself in scope. +macro_rules! raw_preserving_string_enum { + ( + $(#[$meta:meta])* + pub enum $name:ident { + $($variant:ident => $wire_value:literal),+ $(,)? + } + ) => { + $(#[$meta])* + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum $name { + $($variant,)+ + Unknown(String), + } + + impl ::serde::Serialize for $name { + fn serialize(&self, serializer: S) -> ::core::result::Result + where + S: ::serde::Serializer, + { + let value = match self { + $(Self::$variant => $wire_value,)+ + Self::Unknown(raw) => raw.as_str(), + }; + + serializer.serialize_str(value) + } + } + + impl<'de> ::serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> ::core::result::Result + where + D: ::serde::Deserializer<'de>, + { + let raw = <::std::string::String as ::serde::Deserialize>::deserialize(deserializer)?; + Ok(match raw.as_str() { + $($wire_value => Self::$variant,)+ + _ => Self::Unknown(raw), + }) + } + } + }; +} + +pub(crate) use raw_preserving_string_enum; From 1a3eac52ee65a6193602be7e21fa85f56c4df93b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:35:09 -0400 Subject: [PATCH 2/6] feat(intune): deterministic Company Portal Windows package-state evidence Before: the collector ran `Get-AppxPackage -AllUsers | ... | Format-List` and wrote the result to `appx-intune-packages.txt`. Nothing parsed that file, and nothing could have parsed it safely: `Format-List` is a display rendering whose labels are localized and whose field order, wrapping, and truncation vary by console width and PowerShell version. There was no notion of query coverage at all, so an unelevated run that enumerated nothing was indistinguishable from a device with no Company Portal installed. This adds `intune::portal::windows::company_portal::package_state`, a versioned JSON capture schema plus a deterministic findings layer over it, and switches the collector to emit that schema. The capture (`schemaVersion 1`) carries provenance (capture time, adapter version, Windows/PowerShell version, locale), an explicit `commandStatus`, and per-scope `scopeCoverage`. Package rows carry name/family/full name, version, architecture, publisher, signature kind, status, install state, and scope. Every string enum round-trips unrecognized values through `Unknown(String)`, unknown adapter fields are folded into a per-row `raw` bag, and a `schemaVersion` newer than this build is not an error: the raw document is preserved, no package facts are claimed, and an `UnsupportedSchema` finding is emitted. The load-bearing rule is that absence is a claim about the capture, not the device. `PackageAbsentFromCapturedScope` is emitted only when `commandStatus` is `completed` AND the scope's coverage is `complete`. A denied, failed, capped, timed-out, or never-queried scope produces `IncompleteQuery` instead. Both directions are tested. `VersionMismatch` compares against an `ExpectedPackageFact` the caller supplies; the crate never looks a version up. `legacy.rs` can import old `Format-List` text, but refuses rather than guessing: a missing or non-English locale, or any line that is not an unambiguous label/value pair (which is what wrapping and truncation look like), returns `LegacyImportOutcome::Refused` with a reason and a line number. Refusal is a distinct variant from an imported-but-empty capture, imports are stamped `source: legacyFormatList`, their coverage is `partial` so absence stays structurally unclaimable, and every derived finding is capped to low confidence. `redaction.rs` is an opt-in projection, never a mutation: install locations, capture error messages, user identifiers (pseudonymized as `[redacted-user-N]` from a `BTreeSet` so indices are stable), and user-profile paths inside preserved raw blobs are masked. Publisher CN values and versions are left alone so the export stays diagnostically useful. This is the right seam because the schema, not the PowerShell rendering, is the public contract. The pure crate does no I/O, no lookups, and no remediation; the Windows adapter's only job is to fill the envelope truthfully, including reporting its own failure. Two things the design notes got wrong about this codebase, both found by tests: 1. `serde_json` IS built with `preserve_order` in the workspace build (`evtx` enables it and Cargo unifies features), so `serde_json::Map` is an `IndexMap` there and a `BTreeMap` under `cargo test -p cmtraceopen-parser`. The golden serialization tests passed per-crate and failed workspace-wide. Preserved blobs are now canonicalized with sorted keys on the way in, so the bytes are identical under either build. 2. `LegacyImportOutcome::Imported` has to box its payload; `clippy::large_enum_variant` rejects the unboxed form under `-D warnings`. Verified on macOS (commands run from the repo root): npx tsc --noEmit exit 0 cargo check --locked --all-targets exit 0 cargo clippy --locked --all-targets -- -D warnings exit 0 cargo check --locked -p cmtraceopen-parser \ --target wasm32-unknown-unknown exit 0 cargo test --locked -p cmtraceopen-parser \ --test company_portal_windows_package_state 19 passed, 0 failed cargo test --locked 1382 passed, 0 failed (baseline on this branch point: 1362 passed, 0 failed; +19 integration tests and +1 collector profile test) The collector command itself was executed end to end via `pwsh` on macOS, where `Get-AppxPackage` does not exist: it produced a valid envelope with `commandStatus: "failed"`, scope coverage `failed`, and the error detail, which is exactly the shape the contract requires of a failed capture. Not verifiable on this host: the `#[cfg(all(test, target_os = "windows"))]` test in `src-tauri/src/collector/mod.rs`, which runs the embedded adapter command and parses its output. It was compile-checked and clippy-checked by temporarily retargeting the cfg to macOS, but it has never been executed; the Windows CI job is its first real run. Closes #367 Co-Authored-By: Claude Opus 5 --- .../src/collector/profile.rs | 50 ++ .../src/collector/profile_data.json | 6 +- crates/cmtraceopen-parser/src/intune/mod.rs | 1 + .../src/intune/portal/mod.rs | 8 + .../portal/windows/company_portal/mod.rs | 3 + .../company_portal/package_state/findings.rs | 457 +++++++++++ .../company_portal/package_state/legacy.rs | 380 +++++++++ .../company_portal/package_state/mod.rs | 135 ++++ .../company_portal/package_state/models.rs | 380 +++++++++ .../company_portal/package_state/redaction.rs | 107 +++ .../src/intune/portal/windows/mod.rs | 3 + .../company_portal_windows_package_state.rs | 746 ++++++++++++++++++ .../capture.json | 21 + .../capture.json | 25 + .../command-failure/capture.json | 22 + .../deterministic-serialization/capture.json | 38 + .../deterministic-serialization/golden.json | 1 + .../capture.json | 55 ++ .../installed-company-portal/capture.json | 36 + .../legacy-format-list-english/packages.txt | 13 + .../non-english.txt | 6 + .../legacy-format-list-refused/wrapped.txt | 7 + .../package_state/malformed-json/capture.json | 7 + .../multiple-registrations/capture.json | 54 ++ .../package-status-problem/capture.json | 36 + .../per-user-only-registration/capture.json | 41 + .../package_state/privacy-paths/capture.json | 67 ++ .../privacy-paths/golden-redacted.json | 1 + .../unknown-future-schema/capture.json | 29 + src-tauri/src/collector/mod.rs | 71 ++ 30 files changed, 2803 insertions(+), 3 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/intune/portal/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs create mode 100644 crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json diff --git a/crates/cmtraceopen-parser/src/collector/profile.rs b/crates/cmtraceopen-parser/src/collector/profile.rs index 05cbae388..f84eb3836 100644 --- a/crates/cmtraceopen-parser/src/collector/profile.rs +++ b/crates/cmtraceopen-parser/src/collector/profile.rs @@ -144,4 +144,54 @@ mod tests { item.source_pattern ); } + + #[test] + fn embedded_profile_appx_item_emits_versioned_json_capture() { + // Contract guard for issue #367: the Company Portal / Authenticator AppX + // adapter must emit the versioned JSON capture schema consumed by + // intune::portal::windows::company_portal::package_state. Format-List is a + // display rendering whose labels, order, and wrapping vary by locale and + // host version, so it must never be the canonical capture format. + let profile = CollectionProfile::embedded(); + let item = profile + .commands + .iter() + .find(|i| i.id == "appx-info") + .expect("profile must contain the AppX package-state adapter"); + + assert_eq!(item.file_name, "appx-intune-packages.json"); + + let command = item.arguments.join(" "); + assert!( + command.contains("ConvertTo-Json"), + "adapter must serialize with ConvertTo-Json: {command}" + ); + assert!( + !command.contains("Format-List"), + "adapter must not emit display-formatted text: {command}" + ); + for required in [ + "schemaVersion", + "capturedAtUtc", + "commandStatus", + "scopeCoverage", + "accessDenied", + "-Compress", + "-Depth", + ] { + assert!( + command.contains(required), + "adapter command is missing {required}: {command}" + ); + } + assert!( + command.contains("try {") && command.contains("catch {"), + "adapter must wrap the query so a denied -AllUsers scope is reported \ + rather than returning an empty success: {command}" + ); + assert!( + command.is_ascii(), + "adapter command must be ASCII; PowerShell 5.1 mis-parses non-ASCII literals" + ); + } } diff --git a/crates/cmtraceopen-parser/src/collector/profile_data.json b/crates/cmtraceopen-parser/src/collector/profile_data.json index 96abdff26..6880add58 100644 --- a/crates/cmtraceopen-parser/src/collector/profile_data.json +++ b/crates/cmtraceopen-parser/src/collector/profile_data.json @@ -1300,10 +1300,10 @@ "id": "appx-info", "family": "general", "command": "powershell.exe", - "arguments": ["-NoProfile", "-Command", "Get-AppxPackage -AllUsers | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' } | Format-List Name, Version, PackageFullName, Status, SignatureKind"], - "fileName": "appx-intune-packages.txt", + "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"], + "fileName": "appx-intune-packages.json", "timeoutSecs": 30, - "notes": "Company Portal and Authenticator AppX package info." + "notes": "Company Portal and Authenticator AppX package state as the versioned JSON capture schema (schemaVersion 1) consumed by intune::portal::windows::company_portal::package_state. Emits commandStatus and per-scope coverage so a denied -AllUsers query is reported as accessDenied/denied instead of an empty success." }, { "id": "mdm-diag-tool", diff --git a/crates/cmtraceopen-parser/src/intune/mod.rs b/crates/cmtraceopen-parser/src/intune/mod.rs index 13af0222f..2bec60a21 100644 --- a/crates/cmtraceopen-parser/src/intune/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/mod.rs @@ -4,4 +4,5 @@ pub mod guid_registry; pub mod ime_parser; pub mod models; pub mod policy_parser; +pub mod portal; pub mod timeline; diff --git a/crates/cmtraceopen-parser/src/intune/portal/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/mod.rs new file mode 100644 index 000000000..638c3de41 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/mod.rs @@ -0,0 +1,8 @@ +//! Company Portal evidence surfaces. +//! +//! Company Portal spans sign-in, enrollment, the app catalog, compliance, sync, +//! device actions, and support, so it is a first-class surface rather than a +//! sub-case of IME or ESP. Platform-specific contracts live under the matching +//! platform module. + +pub mod windows; diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs new file mode 100644 index 000000000..06c18b199 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs @@ -0,0 +1,3 @@ +//! Windows Company Portal evidence contracts. + +pub mod package_state; diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs new file mode 100644 index 000000000..119e8fa2f --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs @@ -0,0 +1,457 @@ +//! Deterministic findings derived from a package-state capture. +//! +//! Every finding is a statement about the *capture*, not about the device. The +//! central rule is that absence is only claimable when the adapter proved it +//! enumerated the relevant scope: a missing row under a failed, denied, capped, +//! timed-out, or never-queried scope is coverage, not evidence of absence. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::models::{ + ExpectedPackageFact, PackageCaptureCommandStatus, PackageCaptureSource, PackageInstallState, + PackageRow, PackageScope, PackageScopeCoverageStatus, PackageStateCapture, PackageStateError, + PackageStatus, PortalApp, COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, +}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum PackageStateFindingKind { + MalformedCapture, + UnsupportedSchema, + IncompleteQuery, + PackageStatusProblem, + VersionMismatch, + MultiplePackageRegistrations, + PackageAbsentFromCapturedScope, + PackageInstalled, +} + +impl PackageStateFindingKind { + /// Stable presentation rank. Most actionable first; ties break on the + /// finding id so the whole list is a total order. + fn rank(self) -> u8 { + match self { + Self::MalformedCapture => 0, + Self::UnsupportedSchema => 1, + Self::IncompleteQuery => 2, + Self::PackageStatusProblem => 3, + Self::VersionMismatch => 4, + Self::MultiplePackageRegistrations => 5, + Self::PackageAbsentFromCapturedScope => 6, + Self::PackageInstalled => 7, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum PackageStateFindingSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum PackageStateFindingConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub enum PackageStateEvidenceKind { + PackageRow, + CaptureField, + ScopeCoverage, + ExpectedFact, +} + +/// Pointer back to the exact capture element a finding was derived from. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PackageStateEvidenceRef { + pub kind: PackageStateEvidenceKind, + pub package_index: Option, + pub package_full_name: Option, + pub capture_field: Option, + pub scope: Option, +} + +impl PackageStateEvidenceRef { + fn package(index: usize, row: &PackageRow) -> Self { + Self { + kind: PackageStateEvidenceKind::PackageRow, + package_index: Some(index), + package_full_name: Some(row.full_name.clone()), + capture_field: None, + scope: None, + } + } + + fn capture_field(field: &str) -> Self { + Self { + kind: PackageStateEvidenceKind::CaptureField, + package_index: None, + package_full_name: None, + capture_field: Some(field.to_string()), + scope: None, + } + } + + fn scope_coverage(scope: &PackageScope) -> Self { + Self { + kind: PackageStateEvidenceKind::ScopeCoverage, + package_index: None, + package_full_name: None, + capture_field: Some("capture.scopeCoverage".to_string()), + scope: Some(scope.clone()), + } + } + + fn expected_fact(source: &str) -> Self { + Self { + kind: PackageStateEvidenceKind::ExpectedFact, + package_index: None, + package_full_name: None, + capture_field: Some(source.to_string()), + scope: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PackageStateFinding { + pub id: String, + pub kind: PackageStateFindingKind, + pub severity: PackageStateFindingSeverity, + pub confidence: PackageStateFindingConfidence, + pub message: String, + pub evidence: Vec, +} + +/// Serialized wire form of a raw-preserving enum, used to build stable ids and +/// readable messages without a bespoke `Display` impl per enum. +fn wire(value: &impl Serialize) -> String { + match serde_json::to_value(value) { + Ok(serde_json::Value::String(text)) => text, + Ok(other) => other.to_string(), + Err(_) => "unknown".to_string(), + } +} + +/// A legacy `Format-List` import can never be more than a low-confidence +/// reading of display text, so it caps the confidence of everything derived +/// from it. +fn confidence_for( + capture: &PackageStateCapture, + ceiling: PackageStateFindingConfidence, +) -> PackageStateFindingConfidence { + if capture.capture.source == PackageCaptureSource::LegacyFormatList { + PackageStateFindingConfidence::Low + } else { + ceiling + } +} + +/// Build the single finding that represents a capture we could not read. +pub fn malformed_capture_finding(error: &PackageStateError) -> PackageStateFinding { + PackageStateFinding { + id: "package-state/malformed-capture".to_string(), + kind: PackageStateFindingKind::MalformedCapture, + severity: PackageStateFindingSeverity::Error, + confidence: PackageStateFindingConfidence::High, + message: format!("Package state capture could not be read: {error}"), + evidence: vec![PackageStateEvidenceRef::capture_field("$")], + } +} + +/// Derive every finding a capture supports, in a deterministic order. +/// +/// `expected` carries version expectations from other evidence. Nothing here +/// looks a version up; an empty slice simply produces no version findings. +pub fn derive_package_state_findings( + capture: &PackageStateCapture, + expected: &[ExpectedPackageFact], +) -> Vec { + let mut findings = Vec::new(); + + if capture.is_unsupported_schema() { + findings.push(PackageStateFinding { + id: format!( + "package-state/unsupported-schema/{}", + capture.schema_version + ), + kind: PackageStateFindingKind::UnsupportedSchema, + severity: PackageStateFindingSeverity::Warning, + confidence: PackageStateFindingConfidence::High, + message: format!( + "Capture declares schema version {} but this build understands version {}. \ + The raw document is preserved and no package facts are claimed.", + capture.schema_version, COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION + ), + evidence: vec![PackageStateEvidenceRef::capture_field("schemaVersion")], + }); + sort_findings(&mut findings); + return findings; + } + + push_coverage_findings(capture, &mut findings); + push_package_findings(capture, &mut findings); + push_duplicate_registration_findings(capture, &mut findings); + push_version_mismatch_findings(capture, expected, &mut findings); + push_absence_findings(capture, expected, &mut findings); + + sort_findings(&mut findings); + findings +} + +fn sort_findings(findings: &mut [PackageStateFinding]) { + findings.sort_by(|left, right| { + left.kind + .rank() + .cmp(&right.kind.rank()) + .then_with(|| left.id.cmp(&right.id)) + }); +} + +fn push_coverage_findings(capture: &PackageStateCapture, findings: &mut Vec) { + let command_status = &capture.capture.command_status; + if command_status != &PackageCaptureCommandStatus::Completed { + let severity = match command_status { + PackageCaptureCommandStatus::Failed | PackageCaptureCommandStatus::AccessDenied => { + PackageStateFindingSeverity::Error + } + _ => PackageStateFindingSeverity::Warning, + }; + let detail = capture + .capture + .error + .as_ref() + .map(|error| match &error.code { + Some(code) => format!(" ({code}: {})", error.message), + None => format!(" ({})", error.message), + }) + .unwrap_or_default(); + findings.push(PackageStateFinding { + id: format!( + "package-state/incomplete-query/command/{}", + wire(command_status) + ), + kind: PackageStateFindingKind::IncompleteQuery, + severity, + confidence: PackageStateFindingConfidence::High, + message: format!( + "Package enumeration command reported status '{}'{detail}. \ + Package absence cannot be claimed from this capture.", + wire(command_status) + ), + evidence: vec![PackageStateEvidenceRef::capture_field( + "capture.commandStatus", + )], + }); + } + + for coverage in &capture.capture.scope_coverage { + if coverage.status == PackageScopeCoverageStatus::Complete { + continue; + } + let severity = match coverage.status { + PackageScopeCoverageStatus::Denied | PackageScopeCoverageStatus::Failed => { + PackageStateFindingSeverity::Error + } + _ => PackageStateFindingSeverity::Warning, + }; + let detail = coverage + .detail + .as_ref() + .map(|detail| format!(" ({detail})")) + .unwrap_or_default(); + findings.push(PackageStateFinding { + id: format!( + "package-state/incomplete-query/scope/{}/{}", + wire(&coverage.scope), + wire(&coverage.status) + ), + kind: PackageStateFindingKind::IncompleteQuery, + severity, + confidence: PackageStateFindingConfidence::High, + message: format!( + "Scope '{}' coverage is '{}'{detail}. Missing rows in this scope are unknown, not absent.", + wire(&coverage.scope), + wire(&coverage.status) + ), + evidence: vec![PackageStateEvidenceRef::scope_coverage(&coverage.scope)], + }); + } +} + +fn push_package_findings(capture: &PackageStateCapture, findings: &mut Vec) { + for (index, row) in capture.packages.iter().enumerate() { + if row.install_state == PackageInstallState::Installed { + findings.push(PackageStateFinding { + id: format!("package-state/installed/{index}/{}", row.full_name), + kind: PackageStateFindingKind::PackageInstalled, + severity: PackageStateFindingSeverity::Info, + confidence: confidence_for(capture, PackageStateFindingConfidence::High), + message: format!( + "{} {} is installed ({}, {}).", + row.name, + row.version, + wire(&row.architecture), + wire(&row.signature_kind) + ), + evidence: vec![PackageStateEvidenceRef::package(index, row)], + }); + } + + if row.status != PackageStatus::Ok { + findings.push(PackageStateFinding { + id: format!("package-state/status-problem/{index}/{}", row.full_name), + kind: PackageStateFindingKind::PackageStatusProblem, + severity: PackageStateFindingSeverity::Error, + confidence: confidence_for(capture, PackageStateFindingConfidence::High), + message: format!( + "{} {} reports package status '{}' (install state '{}').", + row.name, + row.version, + wire(&row.status), + wire(&row.install_state) + ), + evidence: vec![PackageStateEvidenceRef::package(index, row)], + }); + } + } +} + +fn push_duplicate_registration_findings( + capture: &PackageStateCapture, + findings: &mut Vec, +) { + let mut by_family: BTreeMap<&str, Vec<(usize, &PackageRow)>> = BTreeMap::new(); + for (index, row) in capture.packages.iter().enumerate() { + by_family + .entry(row.family_name.as_str()) + .or_default() + .push((index, row)); + } + + for (family, rows) in by_family { + if rows.len() < 2 { + continue; + } + let mut versions: Vec<&str> = rows.iter().map(|(_, row)| row.version.as_str()).collect(); + versions.sort_unstable(); + versions.dedup(); + findings.push(PackageStateFinding { + id: format!("package-state/multiple-registrations/{family}"), + kind: PackageStateFindingKind::MultiplePackageRegistrations, + severity: PackageStateFindingSeverity::Warning, + confidence: confidence_for(capture, PackageStateFindingConfidence::High), + message: format!( + "Package family '{family}' has {} registrations (versions: {}).", + rows.len(), + versions.join(", ") + ), + evidence: rows + .iter() + .map(|(index, row)| PackageStateEvidenceRef::package(*index, row)) + .collect(), + }); + } +} + +fn push_version_mismatch_findings( + capture: &PackageStateCapture, + expected: &[ExpectedPackageFact], + findings: &mut Vec, +) { + for fact in expected { + for (index, row) in capture.packages.iter().enumerate() { + if row.app != fact.app { + continue; + } + if let Some(family) = &fact.family_name { + if &row.family_name != family { + continue; + } + } + if row.version == fact.expected_version { + continue; + } + findings.push(PackageStateFinding { + id: format!( + "package-state/version-mismatch/{index}/{}", + fact.expected_version + ), + kind: PackageStateFindingKind::VersionMismatch, + severity: PackageStateFindingSeverity::Warning, + confidence: confidence_for(capture, PackageStateFindingConfidence::Medium), + message: format!( + "{} is version {} but the supplied fact from {} expects {}.", + row.name, row.version, fact.source, fact.expected_version + ), + evidence: vec![ + PackageStateEvidenceRef::package(index, row), + PackageStateEvidenceRef::expected_fact(&fact.source), + ], + }); + } + } +} + +/// Absence is claimed only for apps the caller asked about, and only against a +/// scope the adapter proved it enumerated completely. +fn push_absence_findings( + capture: &PackageStateCapture, + expected: &[ExpectedPackageFact], + findings: &mut Vec, +) { + if capture.capture.command_status != PackageCaptureCommandStatus::Completed { + return; + } + let complete_scopes = capture.capture.complete_scopes(); + if complete_scopes.is_empty() { + return; + } + + // Company Portal is the subject of this contract, so it is always checked. + // Any additional app the caller supplied a fact for is checked too. + let mut apps = vec![PortalApp::CompanyPortal]; + for fact in expected { + if !apps.contains(&fact.app) { + apps.push(fact.app.clone()); + } + } + + for app in apps { + if !capture.rows_for_app(&app).is_empty() { + continue; + } + for scope in &complete_scopes { + findings.push(PackageStateFinding { + id: format!( + "package-state/absent/{}/{}", + wire(&app), + wire(scope) + ), + kind: PackageStateFindingKind::PackageAbsentFromCapturedScope, + severity: PackageStateFindingSeverity::Warning, + confidence: confidence_for(capture, PackageStateFindingConfidence::High), + message: format!( + "No '{}' package registration was found in scope '{}', which the adapter enumerated completely.", + wire(&app), + wire(scope) + ), + evidence: vec![ + PackageStateEvidenceRef::scope_coverage(scope), + PackageStateEvidenceRef::capture_field("capture.commandStatus"), + ], + }); + } + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs new file mode 100644 index 000000000..7584e5439 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs @@ -0,0 +1,380 @@ +//! Experimental import of legacy PowerShell `Format-List` package output. +//! +//! `Format-List` is a *display* rendering, not a protocol. Its labels are +//! localized, long values wrap onto continuation lines, and wide consoles +//! truncate. This adapter therefore refuses far more readily than it parses: +//! a refusal is an explicit outcome, distinguishable from "captured nothing", +//! so a caller can never mistake a failed read for evidence of absence. +//! +//! Anything imported here is stamped +//! [`PackageCaptureSource::LegacyFormatList`] and its scope coverage is +//! `partial`, which structurally prevents an absence finding. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::models::{ + canonical_json, PackageArchitecture, PackageCaptureCommandStatus, PackageCaptureSource, + PackageInstallState, PackageRow, PackageScope, PackageScopeCoverage, + PackageScopeCoverageStatus, PackageSignatureKind, PackageStateCapture, + PackageStateCaptureMetadata, PackageStateClassifiedString, PackageStatus, PortalApp, + COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, +}; + +/// Metadata the caller must supply before a legacy text import is attempted. +/// +/// `locale` is required rather than optional-with-a-guess: English field labels +/// cannot be assumed for output produced under another UI culture. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LegacyImportMetadata { + pub locale: Option, + pub adapter_version: String, + pub captured_at_utc: String, + #[serde(default)] + pub windows_build: Option, + #[serde(default)] + pub power_shell_version: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LegacyRefusalReason { + /// No locale was declared, so the labels cannot be trusted to be English. + MissingLocale, + /// A non-English locale was declared; the labels are localized. + UnsupportedLocale, + /// A line could not be resolved into exactly one label/value pair, which is + /// what wrapping and truncation look like. + AmbiguousRecord, + /// A record was recognizable as a record but lacked the identifying label. + IncompleteRecord, + /// Nothing in the text looked like `Format-List` output at all. + NoRecognizableRecords, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LegacyRefusal { + pub reason: LegacyRefusalReason, + pub detail: String, + pub locale: Option, + /// 1-based source line the adapter gave up on, when it can point at one. + pub line_number: Option, +} + +/// Outcome of a legacy import. `Refused` is deliberately a distinct variant +/// from an imported-but-empty capture. +/// +/// The capture is boxed because it dwarfs the refusal; `clippy::large_enum_variant` +/// rejects the unboxed form under this repo's `-D warnings` gate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LegacyImportOutcome { + Imported(Box), + Refused(LegacyRefusal), +} + +impl LegacyImportOutcome { + pub fn imported(&self) -> Option<&PackageStateCapture> { + match self { + Self::Imported(capture) => Some(capture), + Self::Refused(_) => None, + } + } + + pub fn refusal(&self) -> Option<&LegacyRefusal> { + match self { + Self::Refused(refusal) => Some(refusal), + Self::Imported(_) => None, + } + } +} + +/// Import legacy `Format-List` text, or refuse with a reason. +pub fn import_legacy_format_list( + text: &str, + metadata: LegacyImportMetadata, +) -> LegacyImportOutcome { + let Some(locale) = metadata.locale.as_deref() else { + return refuse( + LegacyRefusalReason::MissingLocale, + "Legacy Format-List import requires a declared locale; field labels are localized.", + &metadata, + None, + ); + }; + if !is_english_locale(locale) { + return refuse( + LegacyRefusalReason::UnsupportedLocale, + &format!( + "Locale '{locale}' is not English, so the English field labels this adapter \ + understands cannot be assumed." + ), + &metadata, + None, + ); + } + + let mut records: Vec = Vec::new(); + let mut current = LegacyRecord::default(); + + for (offset, line) in text.lines().enumerate() { + let line_number = offset + 1; + if line.trim().is_empty() { + if !current.is_empty() { + records.push(std::mem::take(&mut current)); + } + continue; + } + + match split_label_value(line) { + Some((label, value)) => current.push(label, value, line_number), + None => { + // A line that is neither blank nor a label/value pair is a + // wrapped continuation or a truncated value. Either way the + // original value cannot be reconstructed, so do not guess. + return refuse( + LegacyRefusalReason::AmbiguousRecord, + &format!( + "Line {line_number} is not a complete label/value pair, which indicates \ + wrapped or truncated Format-List output: {:?}", + line.trim() + ), + &metadata, + Some(line_number), + ); + } + } + } + if !current.is_empty() { + records.push(current); + } + + if records.is_empty() { + return refuse( + LegacyRefusalReason::NoRecognizableRecords, + "No Format-List records were recognized in the supplied text.", + &metadata, + None, + ); + } + + let mut packages = Vec::with_capacity(records.len()); + for record in &records { + match record.to_row() { + Ok(row) => packages.push(row), + Err((reason, detail, line_number)) => { + return refuse(reason, &detail, &metadata, line_number) + } + } + } + + LegacyImportOutcome::Imported(Box::new(PackageStateCapture { + schema_version: COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, + capture: PackageStateCaptureMetadata { + captured_at_utc: metadata.captured_at_utc, + adapter_version: metadata.adapter_version, + command_status: PackageCaptureCommandStatus::Completed, + windows_build: metadata.windows_build, + power_shell_version: metadata.power_shell_version, + locale: metadata.locale, + source: PackageCaptureSource::LegacyFormatList, + // Display output cannot prove which scopes were enumerated, so + // coverage stays partial and absence stays unclaimable. + scope_coverage: vec![PackageScopeCoverage { + scope: PackageScope::AllUsers, + status: PackageScopeCoverageStatus::Partial, + detail: Some( + "Imported from legacy Format-List text, which does not report scope coverage." + .to_string(), + ), + }], + error: None, + }, + packages, + raw_document: None, + })) +} + +fn refuse( + reason: LegacyRefusalReason, + detail: &str, + metadata: &LegacyImportMetadata, + line_number: Option, +) -> LegacyImportOutcome { + LegacyImportOutcome::Refused(LegacyRefusal { + reason, + detail: detail.to_string(), + locale: metadata.locale.clone(), + line_number, + }) +} + +fn is_english_locale(locale: &str) -> bool { + let language = locale + .split(['-', '_']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + language == "en" +} + +/// Split `Label : value` without mistaking a colon inside a value (a drive +/// path, a `CN=` publisher) for the separator. `Format-List` always pads the +/// separator with a space on each side. +fn split_label_value(line: &str) -> Option<(String, String)> { + if line.starts_with(char::is_whitespace) { + // Continuation lines are indented under the value column. + return None; + } + let (label, value) = line.split_once(" : ").or_else(|| { + // A label with an empty value renders as "Label :" with no trailing + // space; accept that, but nothing looser. + line.strip_suffix(" :").map(|label| (label, "")) + })?; + let label = label.trim(); + if label.is_empty() || !label.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ') { + return None; + } + Some((label.to_string(), value.trim_end().to_string())) +} + +#[derive(Debug, Default)] +struct LegacyRecord { + fields: Vec<(String, String)>, + first_line: usize, +} + +impl LegacyRecord { + fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + fn push(&mut self, label: String, value: String, line_number: usize) { + if self.fields.is_empty() { + self.first_line = line_number; + } + self.fields.push((label, value)); + } + + fn get(&self, label: &str) -> Option<&str> { + self.fields + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(label)) + .map(|(_, value)| value.as_str()) + } + + fn to_row(&self) -> Result)> { + let name = self.get("Name").filter(|value| !value.is_empty()).ok_or(( + LegacyRefusalReason::IncompleteRecord, + format!( + "Record starting at line {} has no usable 'Name' label.", + self.first_line + ), + Some(self.first_line), + ))?; + + let full_name = self.get("PackageFullName").unwrap_or_default().to_string(); + let (derived_family, derived_architecture) = derive_from_full_name(&full_name); + + let mut raw = Map::new(); + for (label, value) in &self.fields { + if !LEGACY_KNOWN_LABELS + .iter() + .any(|known| known.eq_ignore_ascii_case(label)) + { + raw.insert(label.clone(), Value::String(value.clone())); + } + } + + Ok(PackageRow { + name: name.to_string(), + family_name: self + .get("PackageFamilyName") + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(derived_family) + .unwrap_or_else(|| name.to_string()), + full_name, + version: self.get("Version").unwrap_or_default().to_string(), + architecture: self + .get("Architecture") + .filter(|value| !value.is_empty()) + .map(camel_case_enum::) + .or(derived_architecture) + .unwrap_or_else(|| PackageArchitecture::Unknown(String::new())), + publisher: self + .get("Publisher") + .filter(|value| !value.is_empty()) + .map(str::to_string), + signature_kind: camel_case_enum::( + self.get("SignatureKind").unwrap_or_default(), + ), + status: camel_case_enum::(self.get("Status").unwrap_or_default()), + // Display text says nothing about per-user install state, and + // inventing one would be a false fact. + install_state: PackageInstallState::Unknown(String::new()), + scopes: Vec::new(), + user_registration_count: None, + user_identifier: None, + install_location: self + .get("InstallLocation") + .filter(|value| !value.is_empty()) + .map(PackageStateClassifiedString::sensitive), + app: classify_app(name), + raw: (!raw.is_empty()).then(|| canonical_json(Value::Object(raw))), + }) + } +} + +const LEGACY_KNOWN_LABELS: &[&str] = &[ + "Name", + "PackageFamilyName", + "PackageFullName", + "Version", + "Architecture", + "Publisher", + "SignatureKind", + "Status", + "InstallLocation", +]; + +/// `Name_Version_Architecture__PublisherId` is the documented AppX full-name +/// shape, so family name and architecture are recoverable without guessing. +fn derive_from_full_name(full_name: &str) -> (Option, Option) { + let parts: Vec<&str> = full_name.split('_').collect(); + if parts.len() < 5 || parts[0].is_empty() { + return (None, None); + } + let publisher_id = parts[parts.len() - 1]; + if publisher_id.is_empty() { + return (None, None); + } + let family = format!("{}_{publisher_id}", parts[0]); + let architecture = + (!parts[2].is_empty()).then(|| camel_case_enum::(parts[2])); + (Some(family), architecture) +} + +/// PowerShell renders these enums in PascalCase; the wire form is camelCase. +fn camel_case_enum Deserialize<'de>>(value: &str) -> T { + let mut chars = value.chars(); + let camel = match chars.next() { + Some(first) => first.to_lowercase().collect::() + chars.as_str(), + None => String::new(), + }; + serde_json::from_value(Value::String(camel)) + .expect("raw-preserving enums accept any string value") +} + +fn classify_app(name: &str) -> PortalApp { + let lowered = name.to_ascii_lowercase(); + if lowered.contains("companyportal") { + PortalApp::CompanyPortal + } else if lowered.contains("authenticator") { + PortalApp::Authenticator + } else { + PortalApp::Other + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs new file mode 100644 index 000000000..43e5b7448 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs @@ -0,0 +1,135 @@ +//! Deterministic Company Portal / Authenticator AppX package-state evidence. +//! +//! The canonical input is a versioned JSON capture produced by the native +//! Windows adapter ([`parse_package_state_capture`]). Findings derived from it +//! ([`derive_package_state_findings`]) never claim package absence unless the +//! capture proved it enumerated the relevant scope. +//! +//! Legacy PowerShell `Format-List` text can be imported through the +//! experimental adapter in [`legacy`], which refuses rather than guessing when +//! the text is localized, wrapped, or truncated. Legacy imports are marked in +//! [`models::PackageStateCaptureMetadata::source`] and never become the +//! canonical serialized form. + +mod findings; +mod legacy; +mod models; +mod redaction; + +pub use findings::*; +pub use legacy::*; +pub use models::*; +pub use redaction::*; + +use serde_json::{Map, Value}; + +/// Parse a JSON package-state capture. +/// +/// A capture whose `schemaVersion` is newer than this build understands is +/// *not* an error: the raw document is retained, no package facts are claimed, +/// and [`derive_package_state_findings`] reports +/// [`PackageStateFindingKind::UnsupportedSchema`]. +pub fn parse_package_state_capture(json: &str) -> Result { + let document: Value = serde_json::from_str(json) + .map_err(|error| PackageStateError::InvalidJson(error.to_string()))?; + + let object = document + .as_object() + .ok_or_else(|| PackageStateError::NotAnObject(json_type_name(&document).to_string()))?; + + let schema_version = object + .get("schemaVersion") + .and_then(Value::as_u64) + .ok_or(PackageStateError::MissingSchemaVersion)?; + let schema_version = u32::try_from(schema_version).unwrap_or(u32::MAX); + + if schema_version > COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION { + // Read only what a future schema cannot have moved: provenance. The + // body is preserved verbatim instead of being guessed at. + let capture = object + .get("capture") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .unwrap_or_default(); + return Ok(PackageStateCapture { + schema_version, + capture, + packages: Vec::new(), + raw_document: Some(canonical_json(document)), + }); + } + + let mut capture: PackageStateCapture = serde_json::from_value(document.clone()).map_err( + |error| PackageStateError::InvalidBody { + version: schema_version, + detail: error.to_string(), + }, + )?; + capture.schema_version = schema_version; + capture.raw_document = None; + preserve_unknown_package_fields(&document, &mut capture); + Ok(capture) +} + +/// Parse and derive in one step, turning a parse failure into the +/// [`PackageStateFindingKind::MalformedCapture`] finding rather than an error. +pub fn parse_package_state_findings( + json: &str, + expected: &[ExpectedPackageFact], +) -> Vec { + match parse_package_state_capture(json) { + Ok(capture) => derive_package_state_findings(&capture, expected), + Err(error) => vec![malformed_capture_finding(&error)], + } +} + +/// Fold adapter fields this schema version does not recognize into each row's +/// `raw` bag so a newer collector never loses data against an older reader. +fn preserve_unknown_package_fields(document: &Value, capture: &mut PackageStateCapture) { + let Some(rows) = document.get("packages").and_then(Value::as_array) else { + return; + }; + + for (row, source) in capture.packages.iter_mut().zip(rows) { + if let Some(source) = source.as_object() { + let unknown: Map = source + .iter() + .filter(|(key, _)| !KNOWN_PACKAGE_ROW_FIELDS.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !unknown.is_empty() { + match row.raw.take() { + Some(Value::Object(mut existing)) => { + existing.extend(unknown); + row.raw = Some(Value::Object(existing)); + } + None => row.raw = Some(Value::Object(unknown)), + Some(other) => { + // An adapter put a non-object in `raw`; keep it and park + // the unknown fields beside it rather than discarding + // either. + let mut merged = unknown; + merged.insert("raw".to_string(), other); + row.raw = Some(Value::Object(merged)); + } + } + } + } + + // Canonicalize whatever ended up in `raw`, including a bag the adapter + // supplied verbatim, so the serialized bytes never depend on which + // serde_json map implementation this build was compiled against. + row.raw = row.raw.take().map(canonical_json); + } +} + +fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs new file mode 100644 index 000000000..23cdb1e73 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs @@ -0,0 +1,380 @@ +//! Versioned capture schema for Windows Company Portal / Authenticator AppX +//! package state. +//! +//! The canonical input is JSON emitted by a native Windows adapter. Human +//! formatted PowerShell output (`Format-List`) is *not* a protocol: its field +//! order, labels, wrapping, and truncation all vary by locale and host version. +//! Everything in this module therefore describes the JSON envelope, and the +//! legacy text adapter in [`super::legacy`] is explicitly experimental. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::wire::raw_preserving_string_enum; + +/// Rebuild a preserved JSON blob with object keys in sorted order. +/// +/// `serde_json::Map` is a `BTreeMap` by default but an insertion-ordered +/// `IndexMap` whenever anything in the build graph enables serde_json's +/// `preserve_order` feature. Cargo unifies features across a workspace, so this +/// crate cannot control which one it is compiled against: in the workspace build +/// `evtx` turns `preserve_order` on, while a standalone +/// `cargo test -p cmtraceopen-parser` leaves it off. +/// +/// Serialization of this schema is a golden-tested contract, so every blob that +/// reaches a `Value` is canonicalized on the way in and the bytes come out the +/// same under either build. Only key *order* is normalized; no key or value is +/// added, dropped, or rewritten. +pub(super) fn canonical_json(value: Value) -> Value { + match value { + Value::Object(entries) => { + let mut sorted: Vec<(String, Value)> = entries.into_iter().collect(); + sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); + Value::Object( + sorted + .into_iter() + .map(|(key, nested)| (key, canonical_json(nested))) + .collect(), + ) + } + Value::Array(items) => Value::Array(items.into_iter().map(canonical_json).collect()), + other => other, + } +} + +/// Wire version of the capture envelope. +/// +/// Breaking changes to the capture shape require an explicit bump. Readers stay +/// tolerant of this version forever; a higher version is reported as +/// [`super::PackageStateFindingKind::UnsupportedSchema`] rather than an error. +pub const COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION: u32 = 1; + +raw_preserving_string_enum! { + /// Outcome of the adapter command that produced the capture. + pub enum PackageCaptureCommandStatus { + Completed => "completed", + Failed => "failed", + AccessDenied => "accessDenied", + TimedOut => "timedOut", + Capped => "capped", + NotRun => "notRun", + } +} + +raw_preserving_string_enum! { + /// How the capture reached the parser. + pub enum PackageCaptureSource { + Json => "json", + LegacyFormatList => "legacyFormatList", + } +} + +raw_preserving_string_enum! { + /// Registration scope a package row was observed in. + /// + /// Per-user registration is expressed as [`PackageScope::CurrentUser`], never + /// as a raw username. + pub enum PackageScope { + CurrentUser => "currentUser", + AllUsers => "allUsers", + Provisioned => "provisioned", + } +} + +raw_preserving_string_enum! { + /// How completely the adapter managed to enumerate one scope. + pub enum PackageScopeCoverageStatus { + Complete => "complete", + Partial => "partial", + Denied => "denied", + Failed => "failed", + NotQueried => "notQueried", + } +} + +raw_preserving_string_enum! { + pub enum PackageArchitecture { + X86 => "x86", + X64 => "x64", + Arm => "arm", + Arm64 => "arm64", + Neutral => "neutral", + } +} + +raw_preserving_string_enum! { + pub enum PackageSignatureKind { + Store => "store", + System => "system", + Enterprise => "enterprise", + Developer => "developer", + None => "none", + } +} + +raw_preserving_string_enum! { + /// AppX package health as reported by the platform. + pub enum PackageStatus { + Ok => "ok", + Modified => "modified", + Tampered => "tampered", + LicenseIssue => "licenseIssue", + NeedsRemediation => "needsRemediation", + NotAvailable => "notAvailable", + } +} + +raw_preserving_string_enum! { + pub enum PackageInstallState { + Installed => "installed", + Staged => "staged", + NotInstalled => "notInstalled", + NeedsRemediation => "needsRemediation", + } +} + +raw_preserving_string_enum! { + /// Which Intune portal app a package row belongs to. + pub enum PortalApp { + CompanyPortal => "companyPortal", + Authenticator => "authenticator", + Other => "other", + } +} + +/// Privacy classification for a scalar that may carry identity or path data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum PackageStateSensitivity { + Public, + Sensitive, + Restricted, +} + +/// A string plus the privacy classification the redaction projection acts on. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PackageStateClassifiedString { + pub value: String, + pub sensitivity: PackageStateSensitivity, +} + +impl PackageStateClassifiedString { + pub fn sensitive(value: impl Into) -> Self { + Self { + value: value.into(), + sensitivity: PackageStateSensitivity::Sensitive, + } + } + + pub fn restricted(value: impl Into) -> Self { + Self { + value: value.into(), + sensitivity: PackageStateSensitivity::Restricted, + } + } +} + +/// Per-scope enumeration coverage. Absence is only claimable against a scope +/// whose status is [`PackageScopeCoverageStatus::Complete`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PackageScopeCoverage { + pub scope: PackageScope, + pub status: PackageScopeCoverageStatus, + #[serde(default)] + pub detail: Option, +} + +/// Adapter-reported failure detail. The message can quote paths or account +/// names, so it is treated as sensitive by the redaction projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PackageCaptureError { + #[serde(default)] + pub code: Option, + pub message: String, +} + +/// Provenance and coverage of one capture run. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct PackageStateCaptureMetadata { + pub captured_at_utc: String, + pub adapter_version: String, + pub command_status: PackageCaptureCommandStatus, + pub windows_build: Option, + pub power_shell_version: Option, + pub locale: Option, + pub source: PackageCaptureSource, + pub scope_coverage: Vec, + pub error: Option, +} + +impl Default for PackageStateCaptureMetadata { + fn default() -> Self { + Self { + captured_at_utc: String::new(), + adapter_version: String::new(), + command_status: PackageCaptureCommandStatus::NotRun, + windows_build: None, + power_shell_version: None, + locale: None, + source: PackageCaptureSource::Json, + scope_coverage: Vec::new(), + error: None, + } + } +} + +impl PackageStateCaptureMetadata { + /// Coverage entry for one scope, if the adapter reported that scope. + pub fn coverage_for(&self, scope: &PackageScope) -> Option<&PackageScopeCoverage> { + self.scope_coverage + .iter() + .find(|coverage| &coverage.scope == scope) + } + + /// Scopes the adapter proved it fully enumerated. + pub fn complete_scopes(&self) -> Vec { + self.scope_coverage + .iter() + .filter(|coverage| coverage.status == PackageScopeCoverageStatus::Complete) + .map(|coverage| coverage.scope.clone()) + .collect() + } +} + +/// One AppX package registration observed by the adapter. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct PackageRow { + pub name: String, + pub family_name: String, + pub full_name: String, + pub version: String, + pub architecture: PackageArchitecture, + pub publisher: Option, + pub signature_kind: PackageSignatureKind, + pub status: PackageStatus, + pub install_state: PackageInstallState, + pub scopes: Vec, + /// Opaque count of per-user registrations. Deliberately not a user list. + pub user_registration_count: Option, + /// Present only when an adapter supplied an identifier despite the schema + /// discouraging it. Always classified and always masked on export. + pub user_identifier: Option, + /// Filesystem path, so privacy-sensitive. + pub install_location: Option, + pub app: PortalApp, + /// Adapter fields this schema version does not recognize, preserved verbatim. + pub raw: Option, +} + +impl Default for PackageRow { + fn default() -> Self { + Self { + name: String::new(), + family_name: String::new(), + full_name: String::new(), + version: String::new(), + architecture: PackageArchitecture::Unknown(String::new()), + publisher: None, + signature_kind: PackageSignatureKind::Unknown(String::new()), + status: PackageStatus::Unknown(String::new()), + install_state: PackageInstallState::Unknown(String::new()), + scopes: Vec::new(), + user_registration_count: None, + user_identifier: None, + install_location: None, + app: PortalApp::Other, + raw: None, + } + } +} + +/// Field names this schema version consumes from a package row. Anything else +/// an adapter emits is folded into [`PackageRow::raw`] rather than dropped. +pub(super) const KNOWN_PACKAGE_ROW_FIELDS: &[&str] = &[ + "name", + "familyName", + "fullName", + "version", + "architecture", + "publisher", + "signatureKind", + "status", + "installState", + "scopes", + "userRegistrationCount", + "userIdentifier", + "installLocation", + "app", + "raw", +]; + +/// A complete package-state capture. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct PackageStateCapture { + pub schema_version: u32, + pub capture: PackageStateCaptureMetadata, + pub packages: Vec, + /// Whole source document, retained only when the schema version is newer + /// than this build understands so nothing is lost across the gap. + pub raw_document: Option, +} + +impl Default for PackageStateCapture { + fn default() -> Self { + Self { + schema_version: COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, + capture: PackageStateCaptureMetadata::default(), + packages: Vec::new(), + raw_document: None, + } + } +} + +impl PackageStateCapture { + /// True when this build cannot interpret the capture body. + pub fn is_unsupported_schema(&self) -> bool { + self.schema_version > COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION + } + + /// Package rows classified as the given portal app. + pub fn rows_for_app(&self, app: &PortalApp) -> Vec<(usize, &PackageRow)> { + self.packages + .iter() + .enumerate() + .filter(|(_, row)| &row.app == app) + .collect() + } +} + +/// A version expectation supplied by the caller from some other evidence +/// source. The parser never invents or looks up an expected version. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExpectedPackageFact { + pub app: PortalApp, + /// Optional narrowing to one package family, when the caller knows it. + #[serde(default)] + pub family_name: Option, + pub expected_version: String, + /// Where the expectation came from, echoed into the finding message. + pub source: String, +} + +/// Failure modes of [`super::parse_package_state_capture`]. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PackageStateError { + #[error("package state capture is not valid JSON: {0}")] + InvalidJson(String), + #[error("package state capture must be a JSON object, found {0}")] + NotAnObject(String), + #[error("package state capture is missing a numeric schemaVersion")] + MissingSchemaVersion, + #[error("package state capture body does not match schema version {version}: {detail}")] + InvalidBody { version: u32, detail: String }, +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs new file mode 100644 index 000000000..6d1f71611 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs @@ -0,0 +1,107 @@ +//! Opt-in redacted export projection for package-state captures. +//! +//! This is a projection, never a mutation: the caller keeps its own fully +//! detailed capture and gets back a parallel value safe to attach to a ticket. +//! It masks only what leaks identity or filesystem layout. Publisher `CN` +//! values and package versions stay intact, because over-redaction destroys the +//! diagnostic value the export exists for. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::OnceLock; + +use regex::Regex; +use serde_json::Value; + +use super::models::{PackageStateCapture, PackageStateClassifiedString}; + +const REDACTED: &str = "[redacted]"; + +/// Return a redacted copy of `capture`, leaving the input untouched. +/// +/// The projection is idempotent: applying it to its own output is a no-op. +pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageStateCapture { + let mut safe = capture.clone(); + + // Stable, order-independent pseudonyms so the same identifier reads the + // same way everywhere in one export. + let identifiers: BTreeSet = safe + .packages + .iter() + .filter_map(|row| row.user_identifier.as_ref()) + .map(|identifier| identifier.value.clone()) + .collect(); + let pseudonyms: BTreeMap = identifiers + .into_iter() + .enumerate() + .map(|(index, value)| (value, format!("[redacted-user-{}]", index + 1))) + .collect(); + + if let Some(error) = safe.capture.error.as_mut() { + error.message = REDACTED.to_string(); + } + + for row in &mut safe.packages { + mask(&mut row.install_location); + if let Some(identifier) = row.user_identifier.as_mut() { + if let Some(pseudonym) = pseudonyms.get(&identifier.value) { + identifier.value = pseudonym.clone(); + } else { + identifier.value = REDACTED.to_string(); + } + } + if let Some(raw) = row.raw.as_mut() { + redact_raw_value(raw, &pseudonyms); + } + } + + if let Some(document) = safe.raw_document.as_mut() { + redact_raw_value(document, &pseudonyms); + } + + safe +} + +fn mask(value: &mut Option) { + if let Some(classified) = value.as_mut() { + classified.value = REDACTED.to_string(); + } +} + +/// Walk a preserved raw blob and mask the two things it can plausibly leak: +/// user-profile paths and identifiers we already pseudonymized elsewhere. +fn redact_raw_value(value: &mut Value, pseudonyms: &BTreeMap) { + match value { + Value::String(text) => { + if let Some(pseudonym) = pseudonyms.get(text.as_str()) { + *text = pseudonym.clone(); + } else if looks_like_user_path(text) { + *text = REDACTED.to_string(); + } + } + Value::Array(items) => { + for item in items { + redact_raw_value(item, pseudonyms); + } + } + Value::Object(entries) => { + for (_, entry) in entries.iter_mut() { + redact_raw_value(entry, pseudonyms); + } + } + _ => {} + } +} + +fn looks_like_user_path(value: &str) -> bool { + user_profile_path_pattern().is_match(value) +} + +fn user_profile_path_pattern() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + // Deliberately narrow: only paths that name a user directory. Program + // Files and WindowsApps paths carry no identity and stay readable. + Regex::new(r"(?i)(?:^|[\\/])(?:users|documents and settings)[\\/][^\\/\r\n]+") + .expect("user profile path pattern must compile") + }) +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs new file mode 100644 index 000000000..d9526e941 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs @@ -0,0 +1,3 @@ +//! Windows Company Portal contracts. + +pub mod company_portal; diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs new file mode 100644 index 000000000..5198200c4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs @@ -0,0 +1,746 @@ +//! Contract tests for `intune::portal::windows::company_portal::package_state`. +//! +//! Fixtures are embedded with `include_str!` because the parser crate performs +//! no filesystem access and must keep compiling for `wasm32-unknown-unknown`. + +use std::collections::BTreeSet; +use std::path::Path; + +use cmtraceopen_parser::intune::portal::windows::company_portal::package_state::{ + derive_package_state_findings, import_legacy_format_list, malformed_capture_finding, + parse_package_state_capture, parse_package_state_findings, redacted_package_state_export, + ExpectedPackageFact, LegacyImportMetadata, LegacyImportOutcome, LegacyRefusalReason, + PackageArchitecture, PackageCaptureCommandStatus, PackageCaptureSource, PackageInstallState, + PackageScope, PackageScopeCoverageStatus, PackageSignatureKind, PackageStateCapture, + PackageStateError, PackageStateFinding, PackageStateFindingConfidence, PackageStateFindingKind, + PackageStateFindingSeverity, PackageStateSensitivity, PackageStatus, PortalApp, + COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, +}; + +const FIXTURE_ROOT: &str = "tests/fixtures/intune/portal/windows/package_state"; + +const INSTALLED_COMPANY_PORTAL: &str = include_str!( + "fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json" +); +const INSTALLED_BOTH: &str = include_str!( + "fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json" +); +const ABSENT_AFTER_COMPLETE_CAPTURE: &str = include_str!( + "fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json" +); +const PER_USER_ONLY: &str = include_str!( + "fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json" +); +const MULTIPLE_REGISTRATIONS: &str = include_str!( + "fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json" +); +const STATUS_PROBLEM: &str = include_str!( + "fixtures/intune/portal/windows/package_state/package-status-problem/capture.json" +); +const ACCESS_DENIED: &str = include_str!( + "fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json" +); +const COMMAND_FAILURE: &str = + include_str!("fixtures/intune/portal/windows/package_state/command-failure/capture.json"); +const MALFORMED_JSON: &str = + include_str!("fixtures/intune/portal/windows/package_state/malformed-json/capture.json"); +const UNKNOWN_FUTURE_SCHEMA: &str = + include_str!("fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json"); +const DETERMINISTIC_SERIALIZATION: &str = include_str!( + "fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json" +); +const DETERMINISTIC_SERIALIZATION_GOLDEN: &str = include_str!( + "fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json" +); +const PRIVACY_PATHS: &str = + include_str!("fixtures/intune/portal/windows/package_state/privacy-paths/capture.json"); +const PRIVACY_PATHS_GOLDEN: &str = + include_str!("fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json"); +const LEGACY_ENGLISH: &str = include_str!( + "fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt" +); +const LEGACY_WRAPPED: &str = include_str!( + "fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt" +); +const LEGACY_NON_ENGLISH: &str = include_str!( + "fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt" +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse(json: &str) -> PackageStateCapture { + parse_package_state_capture(json).expect("fixture must parse") +} + +fn kinds(findings: &[PackageStateFinding]) -> Vec { + findings.iter().map(|finding| finding.kind).collect() +} + +fn of_kind( + findings: &[PackageStateFinding], + kind: PackageStateFindingKind, +) -> Vec<&PackageStateFinding> { + findings + .iter() + .filter(|finding| finding.kind == kind) + .collect() +} + +fn expects_company_portal(version: &str) -> ExpectedPackageFact { + ExpectedPackageFact { + app: PortalApp::CompanyPortal, + family_name: None, + expected_version: version.to_string(), + source: "Intune app assignment".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Fixture 1: installed Company Portal +// --------------------------------------------------------------------------- + +#[test] +fn package_state_installed_company_portal_yields_an_installed_fact() { + let capture = parse(INSTALLED_COMPANY_PORTAL); + + assert_eq!( + capture.schema_version, + COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION + ); + assert_eq!(capture.capture.source, PackageCaptureSource::Json); + assert_eq!( + capture.capture.command_status, + PackageCaptureCommandStatus::Completed + ); + assert_eq!(capture.packages.len(), 1); + + let row = &capture.packages[0]; + assert_eq!(row.app, PortalApp::CompanyPortal); + assert_eq!(row.architecture, PackageArchitecture::X64); + assert_eq!(row.signature_kind, PackageSignatureKind::Store); + assert_eq!(row.status, PackageStatus::Ok); + assert_eq!(row.install_state, PackageInstallState::Installed); + assert_eq!(row.scopes, vec![PackageScope::AllUsers]); + assert_eq!(row.user_registration_count, Some(2)); + assert_eq!( + row.install_location.as_ref().map(|l| l.sensitivity.clone()), + Some(PackageStateSensitivity::Sensitive) + ); + + let findings = derive_package_state_findings(&capture, &[]); + assert_eq!( + kinds(&findings), + vec![PackageStateFindingKind::PackageInstalled] + ); + assert_eq!(findings[0].severity, PackageStateFindingSeverity::Info); + assert_eq!(findings[0].confidence, PackageStateFindingConfidence::High); + assert_eq!(findings[0].evidence[0].package_index, Some(0)); +} + +// --------------------------------------------------------------------------- +// Fixture 2: Company Portal and Authenticator together +// --------------------------------------------------------------------------- + +#[test] +fn package_state_company_portal_and_authenticator_are_separate_rows() { + let capture = parse(INSTALLED_BOTH); + + assert_eq!(capture.packages.len(), 2); + assert_eq!(capture.rows_for_app(&PortalApp::CompanyPortal).len(), 1); + assert_eq!(capture.rows_for_app(&PortalApp::Authenticator).len(), 1); + assert_eq!( + capture.packages[1].architecture, + PackageArchitecture::Neutral + ); + + let findings = derive_package_state_findings(&capture, &[]); + assert_eq!( + kinds(&findings), + vec![ + PackageStateFindingKind::PackageInstalled, + PackageStateFindingKind::PackageInstalled + ] + ); + // Two complete scopes, but distinct family names, so no duplicate finding. + assert!(of_kind( + &findings, + PackageStateFindingKind::MultiplePackageRegistrations + ) + .is_empty()); +} + +// --------------------------------------------------------------------------- +// Fixture 3 + 7: the absence rule, both directions +// --------------------------------------------------------------------------- + +#[test] +fn package_state_absence_requires_complete_scope_coverage() { + let capture = parse(ABSENT_AFTER_COMPLETE_CAPTURE); + assert!(capture.packages.is_empty()); + + let findings = derive_package_state_findings(&capture, &[]); + let absences = of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope, + ); + assert_eq!(absences.len(), 1, "one complete scope, one absence claim"); + assert_eq!(absences[0].evidence[0].scope, Some(PackageScope::AllUsers)); + assert_eq!(absences[0].confidence, PackageStateFindingConfidence::High); + assert!(of_kind(&findings, PackageStateFindingKind::IncompleteQuery).is_empty()); +} + +#[test] +fn package_state_absence_is_not_claimed_when_the_scope_was_denied() { + let capture = parse(ACCESS_DENIED); + assert!(capture.packages.is_empty()); + assert_eq!( + capture.capture.command_status, + PackageCaptureCommandStatus::AccessDenied + ); + + let findings = derive_package_state_findings(&capture, &[expects_company_portal("11.2.401.0")]); + assert!( + of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope + ) + .is_empty(), + "a denied scope is coverage, never evidence of absence: {findings:#?}" + ); + + let incomplete = of_kind(&findings, PackageStateFindingKind::IncompleteQuery); + // One for the command status, one per non-complete scope. + assert_eq!(incomplete.len(), 3); + assert!(incomplete + .iter() + .any(|finding| finding.id.contains("command/accessDenied"))); + assert!(incomplete + .iter() + .any(|finding| finding.id.contains("scope/allUsers/denied"))); + assert!(incomplete + .iter() + .any(|finding| finding.id.contains("scope/provisioned/notQueried"))); +} + +#[test] +fn package_state_absence_is_not_claimed_from_a_scope_that_was_never_queried() { + // The all-users scope is `notQueried`, so absence is unclaimable there even + // though the command itself completed and the current-user scope is clean. + let capture = parse(PER_USER_ONLY); + let expected = [ExpectedPackageFact { + app: PortalApp::Authenticator, + family_name: None, + expected_version: "6.2408.1".to_string(), + source: "Intune app assignment".to_string(), + }]; + + let findings = derive_package_state_findings(&capture, &expected); + let absences = of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope, + ); + assert_eq!( + absences.len(), + 1, + "only the completely enumerated currentUser scope may carry an absence claim" + ); + assert_eq!( + absences[0].evidence[0].scope, + Some(PackageScope::CurrentUser) + ); + assert!(absences[0].id.contains("authenticator")); +} + +// --------------------------------------------------------------------------- +// Fixture 4: per-user registration without a raw username +// --------------------------------------------------------------------------- + +#[test] +fn package_state_per_user_registration_carries_no_raw_username() { + let capture = parse(PER_USER_ONLY); + let row = &capture.packages[0]; + + assert_eq!(row.scopes, vec![PackageScope::CurrentUser]); + assert_eq!(row.user_registration_count, Some(1)); + assert!( + row.user_identifier.is_none(), + "per-user scope must be expressed structurally, not by naming the user" + ); + + let serialized = serde_json::to_string(&capture).expect("capture must serialize"); + assert!(!serialized.contains("userIdentifier\":{")); + assert!( + capture + .capture + .coverage_for(&PackageScope::AllUsers) + .map(|coverage| coverage.status.clone()) + == Some(PackageScopeCoverageStatus::NotQueried) + ); +} + +// --------------------------------------------------------------------------- +// Fixture 5: multiple registrations +// --------------------------------------------------------------------------- + +#[test] +fn package_state_multiple_registrations_of_one_family_are_reported_once() { + let capture = parse(MULTIPLE_REGISTRATIONS); + let findings = derive_package_state_findings(&capture, &[]); + + let duplicates = of_kind( + &findings, + PackageStateFindingKind::MultiplePackageRegistrations, + ); + assert_eq!(duplicates.len(), 1); + assert!(duplicates[0] + .id + .ends_with("Microsoft.CompanyPortal_8wekyb3d8bbwe")); + assert_eq!(duplicates[0].evidence.len(), 2); + assert!(duplicates[0].message.contains("11.1.317.0")); + assert!(duplicates[0].message.contains("11.2.401.0")); + + // Only the `installed` row is an installed fact; the staged one is not. + assert_eq!( + of_kind(&findings, PackageStateFindingKind::PackageInstalled).len(), + 1 + ); +} + +// --------------------------------------------------------------------------- +// Fixture 6: package status problem +// --------------------------------------------------------------------------- + +#[test] +fn package_state_status_problem_is_an_error_finding() { + let capture = parse(STATUS_PROBLEM); + let findings = derive_package_state_findings(&capture, &[]); + + let problems = of_kind(&findings, PackageStateFindingKind::PackageStatusProblem); + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].severity, PackageStateFindingSeverity::Error); + assert!(problems[0].message.contains("needsRemediation")); + // Status problems outrank the informational rows in the ordering. + assert_eq!( + findings[0].kind, + PackageStateFindingKind::PackageStatusProblem + ); +} + +// --------------------------------------------------------------------------- +// Version mismatch is a supplied fact, never a lookup +// --------------------------------------------------------------------------- + +#[test] +fn package_state_version_mismatch_only_comes_from_a_supplied_expected_fact() { + let capture = parse(INSTALLED_COMPANY_PORTAL); + + let without_expectation = derive_package_state_findings(&capture, &[]); + assert!( + of_kind( + &without_expectation, + PackageStateFindingKind::VersionMismatch + ) + .is_empty(), + "no expectation supplied, so no version claim may be invented" + ); + + let matching = derive_package_state_findings(&capture, &[expects_company_portal("11.2.401.0")]); + assert!(of_kind(&matching, PackageStateFindingKind::VersionMismatch).is_empty()); + + let mismatched = derive_package_state_findings(&capture, &[expects_company_portal("12.0.0.0")]); + let mismatches = of_kind(&mismatched, PackageStateFindingKind::VersionMismatch); + assert_eq!(mismatches.len(), 1); + assert_eq!( + mismatches[0].confidence, + PackageStateFindingConfidence::Medium + ); + assert!(mismatches[0].message.contains("Intune app assignment")); + assert!(mismatches[0].message.contains("12.0.0.0")); +} + +// --------------------------------------------------------------------------- +// Fixture 8: command failure +// --------------------------------------------------------------------------- + +#[test] +fn package_state_command_failure_is_coverage_and_blocks_every_package_claim() { + let capture = parse(COMMAND_FAILURE); + let findings = derive_package_state_findings(&capture, &[expects_company_portal("11.2.401.0")]); + + assert!(of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope + ) + .is_empty()); + assert!(of_kind(&findings, PackageStateFindingKind::PackageInstalled).is_empty()); + + let incomplete = of_kind(&findings, PackageStateFindingKind::IncompleteQuery); + assert_eq!(incomplete.len(), 4); + let command_level = incomplete + .iter() + .find(|finding| finding.id.contains("command/failed")) + .expect("command status finding"); + assert_eq!(command_level.severity, PackageStateFindingSeverity::Error); + assert!(command_level.message.contains("CommandNotFoundException")); +} + +// --------------------------------------------------------------------------- +// Fixture 9: malformed JSON +// --------------------------------------------------------------------------- + +#[test] +fn package_state_malformed_json_is_a_typed_error_and_a_finding_not_a_panic() { + let error = parse_package_state_capture(MALFORMED_JSON).expect_err("fixture must not parse"); + assert!(matches!(error, PackageStateError::InvalidJson(_))); + + let findings = parse_package_state_findings(MALFORMED_JSON, &[]); + assert_eq!( + kinds(&findings), + vec![PackageStateFindingKind::MalformedCapture] + ); + assert_eq!(findings[0].severity, PackageStateFindingSeverity::Error); + assert_eq!(findings[0], malformed_capture_finding(&error)); +} + +#[test] +fn package_state_rejects_documents_without_a_usable_schema_version() { + assert!(matches!( + parse_package_state_capture("[]").expect_err("array is not a capture"), + PackageStateError::NotAnObject(_) + )); + assert!(matches!( + parse_package_state_capture("{\"capture\":{}}").expect_err("no schemaVersion"), + PackageStateError::MissingSchemaVersion + )); + assert!(matches!( + parse_package_state_capture("{\"schemaVersion\":1,\"packages\":\"nope\"}") + .expect_err("packages must be an array"), + PackageStateError::InvalidBody { version: 1, .. } + )); +} + +// --------------------------------------------------------------------------- +// Fixture 10: unknown future schema +// --------------------------------------------------------------------------- + +#[test] +fn package_state_unknown_future_schema_preserves_raw_metadata_and_claims_nothing() { + let capture = parse(UNKNOWN_FUTURE_SCHEMA); + + assert_eq!(capture.schema_version, 4); + assert!(capture.is_unsupported_schema()); + assert!( + capture.packages.is_empty(), + "a schema we cannot read yields no package facts" + ); + + // Provenance a future schema cannot have moved is still readable, and an + // unrecognized scope value survives as its raw string. + assert_eq!(capture.capture.locale.as_deref(), Some("en-GB")); + assert_eq!( + capture.capture.scope_coverage[1].scope, + PackageScope::Unknown("containerUser".to_string()) + ); + + let raw = capture + .raw_document + .as_ref() + .expect("raw document preserved"); + assert_eq!( + raw.pointer("/packageGroups/0/members/0/trustTier") + .and_then(|value| value.as_str()), + Some("sealedStore") + ); + + let findings = derive_package_state_findings(&capture, &[expects_company_portal("14.0.0.0")]); + assert_eq!( + kinds(&findings), + vec![PackageStateFindingKind::UnsupportedSchema] + ); + assert!(findings[0].message.contains("schema version 4")); +} + +// --------------------------------------------------------------------------- +// Fixture 11: deterministic, field-order-independent serialization +// --------------------------------------------------------------------------- + +#[test] +fn package_state_serialization_is_byte_exact_and_field_order_independent() { + let capture = parse(DETERMINISTIC_SERIALIZATION); + let golden = DETERMINISTIC_SERIALIZATION_GOLDEN.trim_end_matches('\n'); + + let serialized = serde_json::to_string(&capture).expect("capture must serialize"); + assert_eq!(serialized, golden); + + // Input field order does not reach the output. + let reparsed = parse(&serialized); + assert_eq!(reparsed, capture); + assert_eq!( + serde_json::to_string(&reparsed).expect("round trip must serialize"), + golden + ); + + // Unknown enum values and unknown adapter fields both survive the trip. + let row = &capture.packages[0]; + assert_eq!( + row.signature_kind, + PackageSignatureKind::Unknown("quantumAttested".to_string()) + ); + let raw = row.raw.as_ref().expect("unknown fields preserved"); + assert_eq!(raw.get("aaaFutureField").and_then(|v| v.as_u64()), Some(42)); + assert_eq!( + raw.pointer("/zzzFutureField/kind").and_then(|v| v.as_str()), + Some("sealed") + ); +} + +#[test] +fn package_state_finding_order_is_stable_across_input_permutations() { + let capture = parse(MULTIPLE_REGISTRATIONS); + let expected = [expects_company_portal("12.0.0.0")]; + + let first = derive_package_state_findings(&capture, &expected); + let second = derive_package_state_findings(&capture, &expected); + assert_eq!(first, second); + + let mut reversed = capture.clone(); + reversed.packages.reverse(); + let reordered = derive_package_state_findings(&reversed, &expected); + + // Row indices differ, but the finding kinds arrive in the same order. + assert_eq!(kinds(&first), kinds(&reordered)); + let ranks: Vec<_> = first.iter().map(|finding| finding.kind).collect(); + let mut sorted = ranks.clone(); + sorted.sort(); + assert_eq!(ranks, sorted, "findings must be emitted most-severe first"); +} + +// --------------------------------------------------------------------------- +// Fixture 12: legacy Format-List English sample imports +// --------------------------------------------------------------------------- + +#[test] +fn package_state_legacy_english_format_list_imports_as_low_confidence_evidence() { + let outcome = import_legacy_format_list(LEGACY_ENGLISH, english_legacy_metadata()); + let capture = outcome + .imported() + .expect("English sample must import") + .clone(); + + assert_eq!( + capture.capture.source, + PackageCaptureSource::LegacyFormatList + ); + assert_eq!(capture.packages.len(), 2); + + let portal = &capture.packages[0]; + assert_eq!(portal.name, "Microsoft.CompanyPortal"); + assert_eq!(portal.version, "11.2.401.0"); + assert_eq!(portal.app, PortalApp::CompanyPortal); + assert_eq!(portal.status, PackageStatus::Ok); + assert_eq!(portal.signature_kind, PackageSignatureKind::Store); + // Family name and architecture are recovered from the documented full-name + // shape rather than guessed. + assert_eq!(portal.family_name, "Microsoft.CompanyPortal_8wekyb3d8bbwe"); + assert_eq!(portal.architecture, PackageArchitecture::X64); + // Display text says nothing about install state, so nothing is claimed. + assert_eq!( + portal.install_state, + PackageInstallState::Unknown(String::new()) + ); + + let authenticator = &capture.packages[1]; + assert_eq!(authenticator.app, PortalApp::Authenticator); + assert_eq!( + authenticator + .raw + .as_ref() + .and_then(|raw| raw.get("IsBundle")) + .and_then(|value| value.as_str()), + Some("False"), + "unrecognized legacy labels are preserved rather than dropped" + ); + + // A legacy import can never be the basis of an absence claim, and every + // finding it produces is low confidence. + let findings = derive_package_state_findings(&capture, &[expects_company_portal("12.0.0.0")]); + assert!(of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope + ) + .is_empty()); + assert!(findings + .iter() + .filter(|finding| finding.kind != PackageStateFindingKind::IncompleteQuery) + .all(|finding| finding.confidence == PackageStateFindingConfidence::Low)); +} + +// --------------------------------------------------------------------------- +// Fixture 13: wrapped / non-English legacy samples refuse +// --------------------------------------------------------------------------- + +#[test] +fn package_state_legacy_import_refuses_wrapped_or_non_english_samples() { + let wrapped = import_legacy_format_list(LEGACY_WRAPPED, english_legacy_metadata()); + let refusal = wrapped.refusal().expect("wrapped sample must refuse"); + assert_eq!(refusal.reason, LegacyRefusalReason::AmbiguousRecord); + assert_eq!(refusal.line_number, Some(5)); + assert!(wrapped.imported().is_none()); + + let mut german = english_legacy_metadata(); + german.locale = Some("de-DE".to_string()); + let non_english = import_legacy_format_list(LEGACY_NON_ENGLISH, german); + assert_eq!( + non_english.refusal().map(|refusal| refusal.reason), + Some(LegacyRefusalReason::UnsupportedLocale) + ); + + let mut anonymous = english_legacy_metadata(); + anonymous.locale = None; + assert_eq!( + import_legacy_format_list(LEGACY_ENGLISH, anonymous) + .refusal() + .map(|refusal| refusal.reason), + Some(LegacyRefusalReason::MissingLocale) + ); + + // Refusal must be distinguishable from "captured nothing". + let empty = import_legacy_format_list(" \n\n", english_legacy_metadata()); + assert_eq!( + empty.refusal().map(|refusal| refusal.reason), + Some(LegacyRefusalReason::NoRecognizableRecords) + ); + assert!( + !matches!(empty, LegacyImportOutcome::Imported(_)), + "an empty capture must never stand in for a refusal" + ); +} + +fn english_legacy_metadata() -> LegacyImportMetadata { + LegacyImportMetadata { + locale: Some("en-US".to_string()), + adapter_version: "cmtraceopen-legacy-format-list/0".to_string(), + captured_at_utc: "2026-07-14T09:44:00.0000000Z".to_string(), + windows_build: Some("10.0.26100.0".to_string()), + power_shell_version: Some("5.1.26100.2161".to_string()), + } +} + +// --------------------------------------------------------------------------- +// Fixture 14: privacy projection +// --------------------------------------------------------------------------- + +#[test] +fn package_state_redacted_export_masks_paths_and_user_identifiers_without_mutating_input() { + let capture = parse(PRIVACY_PATHS); + let original_json = serde_json::to_string(&capture).expect("capture must serialize"); + + let safe = redacted_package_state_export(&capture); + let golden = PRIVACY_PATHS_GOLDEN.trim_end_matches('\n'); + assert_eq!( + serde_json::to_string(&safe).expect("projection must serialize"), + golden + ); + + // Nothing identity-bearing survives. + let safe_json = serde_json::to_string(&safe).expect("projection must serialize"); + for secret in [ + "jrivera", + "amorel", + "CONTOSO", + "AppData", + "Failed reading manifest", + ] { + assert!( + !safe_json.contains(secret), + "redacted export still leaks {secret}: {safe_json}" + ); + } + + // Pseudonyms are stable and distinguish the two users. + assert_eq!( + safe.packages[0] + .user_identifier + .as_ref() + .map(|id| id.value.as_str()), + Some("[redacted-user-2]") + ); + assert_eq!( + safe.packages[1] + .user_identifier + .as_ref() + .map(|id| id.value.as_str()), + Some("[redacted-user-1]") + ); + + // Diagnostics that carry no identity survive intact. + assert_eq!( + safe.packages[0].publisher.as_deref(), + capture.packages[0].publisher.as_deref() + ); + assert_eq!(safe.packages[0].version, "11.2.401.0"); + assert_eq!( + safe.capture.error.as_ref().and_then(|e| e.code.as_deref()), + Some("AppxPackagingFailure") + ); + assert_eq!( + safe.packages[0] + .raw + .as_ref() + .and_then(|raw| raw.get("packageRoot")) + .and_then(|value| value.as_str()), + Some("C:\\Program Files\\WindowsApps"), + "non-identity paths must stay readable" + ); + + // Projection, not mutation, and idempotent. + assert_eq!(redacted_package_state_export(&capture), safe); + assert_eq!( + serde_json::to_string(&capture).expect("input must be unchanged"), + original_json + ); + assert_eq!(redacted_package_state_export(&safe), safe); +} + +// --------------------------------------------------------------------------- +// Fixture layout guard +// --------------------------------------------------------------------------- + +#[test] +fn package_state_fixture_matrix_covers_every_required_scenario() { + // The issue's 14-item matrix maps onto these scenario directories. Asserting + // the directory listing makes a silently dropped or renamed scenario a test + // failure rather than a quietly narrower contract. + let expected: BTreeSet = [ + "absent-after-complete-all-users-capture", + "access-denied-incomplete-query", + "command-failure", + "deterministic-serialization", + "installed-company-portal", + "installed-company-portal-and-authenticator", + "legacy-format-list-english", + "legacy-format-list-refused", + "malformed-json", + "multiple-registrations", + "package-status-problem", + "per-user-only-registration", + "privacy-paths", + "unknown-future-schema", + ] + .iter() + .map(|name| (*name).to_string()) + .collect(); + assert_eq!(expected.len(), 14); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_ROOT); + let actual: BTreeSet = std::fs::read_dir(&root) + .unwrap_or_else(|error| panic!("fixture root {} must exist: {error}", root.display())) + .map(|entry| entry.expect("readable fixture entry")) + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + + assert_eq!(actual, expected); +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json new file mode 100644 index 000000000..8cf2367e4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:20:11.5550000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { + "scope": "allUsers", + "status": "complete", + "detail": "Elevated enumeration returned zero matching registrations." + } + ], + "error": null + }, + "packages": [] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json new file mode 100644 index 000000000..a7b7f64fb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:22:39.7710000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "accessDenied", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { + "scope": "allUsers", + "status": "denied", + "detail": "Get-AppxPackage -AllUsers requires an elevated session." + }, + { "scope": "provisioned", "status": "notQueried", "detail": null } + ], + "error": { + "code": "Microsoft.Windows.Appx.PackageManager.Commands.GetAppxPackageCommand", + "message": "Access is denied enumerating packages for C:\\Users\\jrivera." + } + }, + "packages": [] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json new file mode 100644 index 000000000..9a99e06ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:25:03.3310000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "failed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "failed", "detail": null }, + { "scope": "currentUser", "status": "failed", "detail": null }, + { "scope": "provisioned", "status": "notQueried", "detail": null } + ], + "error": { + "code": "CommandNotFoundException", + "message": "The term 'Get-AppxPackage' is not recognized as a name of a cmdlet." + } + }, + "packages": [] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json new file mode 100644 index 000000000..e342895fe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json @@ -0,0 +1,38 @@ +{ + "packages": [ + { + "status": "ok", + "installLocation": { + "sensitivity": "sensitive", + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe" + }, + "app": "companyPortal", + "version": "11.2.401.0", + "name": "Microsoft.CompanyPortal", + "signatureKind": "quantumAttested", + "scopes": ["provisioned", "allUsers"], + "architecture": "x64", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "installState": "installed", + "zzzFutureField": { "kind": "sealed" }, + "aaaFutureField": 42, + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "userRegistrationCount": 3 + } + ], + "capture": { + "source": "json", + "scopeCoverage": [ + { "status": "complete", "scope": "allUsers", "detail": null } + ], + "commandStatus": "completed", + "locale": "en-US", + "adapterVersion": "cmtraceopen-collector-appx/1", + "error": null, + "capturedAtUtc": "2026-07-14T09:12:44.1230000Z", + "powerShellVersion": "5.1.26100.2161", + "windowsBuild": "10.0.26100.0" + }, + "schemaVersion": 1 +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json new file mode 100644 index 000000000..181c345d3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json @@ -0,0 +1 @@ +{"schemaVersion":1,"capture":{"capturedAtUtc":"2026-07-14T09:12:44.1230000Z","adapterVersion":"cmtraceopen-collector-appx/1","commandStatus":"completed","windowsBuild":"10.0.26100.0","powerShellVersion":"5.1.26100.2161","locale":"en-US","source":"json","scopeCoverage":[{"scope":"allUsers","status":"complete","detail":null}],"error":null},"packages":[{"name":"Microsoft.CompanyPortal","familyName":"Microsoft.CompanyPortal_8wekyb3d8bbwe","fullName":"Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe","version":"11.2.401.0","architecture":"x64","publisher":"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US","signatureKind":"quantumAttested","status":"ok","installState":"installed","scopes":["provisioned","allUsers"],"userRegistrationCount":3,"userIdentifier":null,"installLocation":{"value":"C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe","sensitivity":"sensitive"},"app":"companyPortal","raw":{"aaaFutureField":42,"zzzFutureField":{"kind":"sealed"}}}],"rawDocument":null} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json new file mode 100644 index 000000000..881a9b7c0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:14:02.0080000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null }, + { "scope": "provisioned", "status": "complete", "detail": null } + ], + "error": null + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["allUsers", "provisioned"], + "userRegistrationCount": 2, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + }, + { + "name": "Microsoft.AAD.BrokerPlugin.Authenticator", + "familyName": "Microsoft.AAD.BrokerPlugin.Authenticator_8wekyb3d8bbwe", + "fullName": "Microsoft.AAD.BrokerPlugin.Authenticator_6.2408.1_neutral__8wekyb3d8bbwe", + "version": "6.2408.1", + "architecture": "neutral", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "system", + "status": "ok", + "installState": "installed", + "scopes": ["allUsers"], + "userRegistrationCount": 1, + "installLocation": { + "value": "C:\\Windows\\SystemApps\\Microsoft.AAD.BrokerPlugin.Authenticator_8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "authenticator" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json new file mode 100644 index 000000000..d788f7c74 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:12:44.1230000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ], + "error": null + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["allUsers"], + "userRegistrationCount": 2, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt new file mode 100644 index 000000000..475404296 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt @@ -0,0 +1,13 @@ + +Name : Microsoft.CompanyPortal +Version : 11.2.401.0 +PackageFullName : Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe +Status : Ok +SignatureKind : Store + +Name : Microsoft.AAD.BrokerPlugin.Authenticator +Version : 6.2408.1 +PackageFullName : Microsoft.AAD.BrokerPlugin.Authenticator_6.2408.1_neutral__8wekyb3d8bbwe +Status : Ok +SignatureKind : System +IsBundle : False diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt new file mode 100644 index 000000000..a1b908590 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt @@ -0,0 +1,6 @@ + +Name : Microsoft.CompanyPortal +Version : 11.2.401.0 +PaketVollstName : Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe +Status : Ok +Signaturart : Store diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt new file mode 100644 index 000000000..562f653fe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt @@ -0,0 +1,7 @@ + +Name : Microsoft.CompanyPortal +Version : 11.2.401.0 +PackageFullName : Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3 + d8bbwe +Status : Ok +SignatureKind : Store diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json new file mode 100644 index 000000000..eeb9a9c51 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:36:22.0000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "packages": [ diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json new file mode 100644 index 000000000..91669757a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:31:15.4400000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ], + "error": null + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["allUsers"], + "userRegistrationCount": 1, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + }, + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.1.317.0_x64__8wekyb3d8bbwe", + "version": "11.1.317.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "staged", + "scopes": ["provisioned"], + "userRegistrationCount": 0, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.1.317.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json new file mode 100644 index 000000000..f60dff2ba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:34:57.6600000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ], + "error": null + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "needsRemediation", + "installState": "needsRemediation", + "scopes": ["allUsers"], + "userRegistrationCount": 1, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json new file mode 100644 index 000000000..744768c55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:27:48.9020000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "currentUser", "status": "complete", "detail": null }, + { + "scope": "allUsers", + "status": "notQueried", + "detail": "Session is not elevated; all-users enumeration was skipped." + } + ], + "error": null + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["currentUser"], + "userRegistrationCount": 1, + "installLocation": { + "value": "C:\\Program Files\\WindowsApps\\Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json new file mode 100644 index 000000000..04001edc2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:41:07.1000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "windowsBuild": "10.0.26100.0", + "powerShellVersion": "5.1.26100.2161", + "locale": "en-US", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ], + "error": { + "code": "AppxPackagingFailure", + "message": "Failed reading manifest under C:\\Users\\jrivera\\AppData\\Local\\Packages for CONTOSO\\jrivera." + } + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["currentUser"], + "userRegistrationCount": 1, + "userIdentifier": { + "value": "CONTOSO\\jrivera", + "sensitivity": "restricted" + }, + "installLocation": { + "value": "C:\\Users\\jrivera\\AppData\\Local\\Packages\\Microsoft.CompanyPortal_8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "companyPortal", + "profileRoot": "C:\\Users\\jrivera", + "packageRoot": "C:\\Program Files\\WindowsApps" + }, + { + "name": "Microsoft.AAD.BrokerPlugin.Authenticator", + "familyName": "Microsoft.AAD.BrokerPlugin.Authenticator_8wekyb3d8bbwe", + "fullName": "Microsoft.AAD.BrokerPlugin.Authenticator_6.2408.1_neutral__8wekyb3d8bbwe", + "version": "6.2408.1", + "architecture": "neutral", + "publisher": "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + "signatureKind": "system", + "status": "ok", + "installState": "installed", + "scopes": ["currentUser"], + "userRegistrationCount": 1, + "userIdentifier": { + "value": "CONTOSO\\amorel", + "sensitivity": "restricted" + }, + "installLocation": { + "value": "C:\\Users\\amorel\\AppData\\Local\\Packages\\Microsoft.AAD.BrokerPlugin.Authenticator_8wekyb3d8bbwe", + "sensitivity": "sensitive" + }, + "app": "authenticator" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json new file mode 100644 index 000000000..726c4579a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json @@ -0,0 +1 @@ +{"schemaVersion":1,"capture":{"capturedAtUtc":"2026-07-14T09:41:07.1000000Z","adapterVersion":"cmtraceopen-collector-appx/1","commandStatus":"completed","windowsBuild":"10.0.26100.0","powerShellVersion":"5.1.26100.2161","locale":"en-US","source":"json","scopeCoverage":[{"scope":"allUsers","status":"complete","detail":null}],"error":{"code":"AppxPackagingFailure","message":"[redacted]"}},"packages":[{"name":"Microsoft.CompanyPortal","familyName":"Microsoft.CompanyPortal_8wekyb3d8bbwe","fullName":"Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe","version":"11.2.401.0","architecture":"x64","publisher":"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US","signatureKind":"store","status":"ok","installState":"installed","scopes":["currentUser"],"userRegistrationCount":1,"userIdentifier":{"value":"[redacted-user-2]","sensitivity":"restricted"},"installLocation":{"value":"[redacted]","sensitivity":"sensitive"},"app":"companyPortal","raw":{"packageRoot":"C:\\Program Files\\WindowsApps","profileRoot":"[redacted]"}},{"name":"Microsoft.AAD.BrokerPlugin.Authenticator","familyName":"Microsoft.AAD.BrokerPlugin.Authenticator_8wekyb3d8bbwe","fullName":"Microsoft.AAD.BrokerPlugin.Authenticator_6.2408.1_neutral__8wekyb3d8bbwe","version":"6.2408.1","architecture":"neutral","publisher":"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US","signatureKind":"system","status":"ok","installState":"installed","scopes":["currentUser"],"userRegistrationCount":1,"userIdentifier":{"value":"[redacted-user-1]","sensitivity":"restricted"},"installLocation":{"value":"[redacted]","sensitivity":"sensitive"},"app":"authenticator","raw":null}],"rawDocument":null} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json new file mode 100644 index 000000000..7e373f4fd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 4, + "capture": { + "capturedAtUtc": "2027-01-05T18:40:00.0000000Z", + "adapterVersion": "cmtraceopen-collector-appx/4", + "commandStatus": "completed", + "windowsBuild": "10.0.28000.0", + "powerShellVersion": "7.6.1", + "locale": "en-GB", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null }, + { "scope": "containerUser", "status": "complete", "detail": null } + ], + "error": null + }, + "packageGroups": [ + { + "groupKind": "msixBundle", + "members": [ + { + "name": "Microsoft.CompanyPortal", + "version": "14.0.0.0", + "trustTier": "sealedStore" + } + ] + } + ] +} diff --git a/src-tauri/src/collector/mod.rs b/src-tauri/src/collector/mod.rs index ef6799934..82866a131 100644 --- a/src-tauri/src/collector/mod.rs +++ b/src-tauri/src/collector/mod.rs @@ -13,3 +13,74 @@ pub use cmtraceopen_parser::collector::{env_expand, profile, types}; pub mod artifacts; pub mod engine; pub mod manifest; + +// Windows-only proof that the native AppX adapter really emits the documented +// package-state schema. The pure crate can only assert the shape of the command +// string; running it needs a Windows host, so this is gated and exercised by the +// Windows CI job rather than on macOS or Linux. +#[cfg(all(test, target_os = "windows"))] +mod appx_adapter_windows_tests { + use cmtraceopen_parser::intune::portal::windows::company_portal::package_state::{ + parse_package_state_capture, PackageCaptureCommandStatus, PackageCaptureSource, + COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, + }; + + use super::types::CollectionProfile; + + #[test] + fn appx_adapter_emits_a_parseable_package_state_capture() { + let profile = CollectionProfile::embedded(); + let item = profile + .commands + .iter() + .find(|item| item.id == "appx-info") + .expect("profile must contain the AppX package-state adapter"); + + let output = std::process::Command::new(&item.command) + .args(&item.arguments) + .output() + .expect("AppX adapter command must run"); + assert!( + output.status.success(), + "adapter exited with {:?}: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let capture = parse_package_state_capture(stdout.trim()) + .expect("adapter output must parse as a package-state capture"); + + assert_eq!( + capture.schema_version, + COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION + ); + assert_eq!(capture.capture.source, PackageCaptureSource::Json); + assert!(!capture.capture.captured_at_utc.is_empty()); + assert!(!capture.capture.adapter_version.is_empty()); + assert!( + !capture.capture.scope_coverage.is_empty(), + "the adapter must always report scope coverage" + ); + + // An unelevated CI agent gets accessDenied; an elevated one gets + // completed. Either is a correctly reported outcome, and neither may be + // an empty success with no coverage. + assert!( + matches!( + capture.capture.command_status, + PackageCaptureCommandStatus::Completed + | PackageCaptureCommandStatus::AccessDenied + | PackageCaptureCommandStatus::Failed + ), + "unexpected command status: {:?}", + capture.capture.command_status + ); + if capture.capture.command_status != PackageCaptureCommandStatus::Completed { + assert!( + capture.capture.error.is_some(), + "a non-completed capture must carry the failure detail" + ); + } + } +} From f5a5feb6c530a9ea1b7da6c1a00cafe9adc0a53f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:56:13 -0400 Subject: [PATCH 3/6] fix(intune): keep the package-state redaction projection idempotent The redacted export numbered user pseudonyms by lexicographic position in a BTreeSet of the raw identifier values, and a second pass re-pseudonymized the pseudonyms it had just produced. Because "[redacted-user-10]" sorts before "[redacted-user-1]" ('0' < ']'), any capture holding ten or more distinct identifiers came back renumbered, so applying the projection to its own output was not a no-op. That contradicted the function's own doc comment and broke the stable-pseudonym guarantee across repeated exports of one capture. The existing test used two identifiers, where lexicographic and numeric order coincide, so it could not catch this. An already-redacted value is now terminal: is_redaction_marker recognizes both the plain mask and a numbered pseudonym, those values are excluded from the numbering, and every write site leaves them untouched. Idempotence therefore no longer depends on how many identifiers a capture holds. Also carries the parseHints convention forward onto appx-info. Every other JSON-emitting command in the collection profile declares a "json" hint, and the evidence-bundle dialog searches on it, so converting this artifact from Format-List text to JSON without the hint left it undiscoverable by that search. Verified: the new test fails against the previous implementation and passes against this one. cargo test --locked 1383 passed / 0 failed (1382 before, plus the regression test); cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0. Refs #367 Co-Authored-By: Claude Opus 5 --- .../src/collector/profile.rs | 8 ++ .../src/collector/profile_data.json | 1 + .../company_portal/package_state/redaction.rs | 42 ++++++++-- .../company_portal_windows_package_state.rs | 78 +++++++++++++++++++ 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/src/collector/profile.rs b/crates/cmtraceopen-parser/src/collector/profile.rs index f84eb3836..f40607909 100644 --- a/crates/cmtraceopen-parser/src/collector/profile.rs +++ b/crates/cmtraceopen-parser/src/collector/profile.rs @@ -193,5 +193,13 @@ mod tests { command.is_ascii(), "adapter command must be ASCII; PowerShell 5.1 mis-parses non-ASCII literals" ); + // Every other JSON-emitting command in this profile carries a "json" + // hint, and the evidence-bundle dialog searches on it. Omitting it here + // would leave this artifact undiscoverable by that search. + assert!( + item.parse_hints.iter().any(|hint| hint == "json"), + "JSON-emitting adapters must declare a json parse hint: {:?}", + item.parse_hints + ); } } diff --git a/crates/cmtraceopen-parser/src/collector/profile_data.json b/crates/cmtraceopen-parser/src/collector/profile_data.json index 6880add58..f74cbcc4f 100644 --- a/crates/cmtraceopen-parser/src/collector/profile_data.json +++ b/crates/cmtraceopen-parser/src/collector/profile_data.json @@ -1299,6 +1299,7 @@ { "id": "appx-info", "family": "general", + "parseHints": ["json", "package-state"], "command": "powershell.exe", "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"], "fileName": "appx-intune-packages.json", diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs index 6d1f71611..6bb50bd77 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs @@ -19,16 +19,26 @@ const REDACTED: &str = "[redacted]"; /// Return a redacted copy of `capture`, leaving the input untouched. /// /// The projection is idempotent: applying it to its own output is a no-op. +/// Idempotence rests on [`is_redaction_marker`]. An already-redacted value is +/// terminal and is never renumbered, so the identifier count cannot affect the +/// result of a second pass. pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageStateCapture { let mut safe = capture.clone(); - // Stable, order-independent pseudonyms so the same identifier reads the - // same way everywhere in one export. + // Pseudonyms are independent of the order rows appear in, so the same + // identifier reads the same way everywhere in one export. + // + // Values that are already redacted are excluded from the numbering. Were + // they included, a second pass would sort the pseudonyms themselves and + // renumber them, because "[redacted-user-10]" sorts before + // "[redacted-user-1]" ('0' < ']'). That reshuffles which row owns which + // label as soon as a capture holds ten or more identifiers. let identifiers: BTreeSet = safe .packages .iter() .filter_map(|row| row.user_identifier.as_ref()) .map(|identifier| identifier.value.clone()) + .filter(|value| !is_redaction_marker(value)) .collect(); let pseudonyms: BTreeMap = identifiers .into_iter() @@ -43,10 +53,11 @@ pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageSt for row in &mut safe.packages { mask(&mut row.install_location); if let Some(identifier) = row.user_identifier.as_mut() { - if let Some(pseudonym) = pseudonyms.get(&identifier.value) { - identifier.value = pseudonym.clone(); - } else { - identifier.value = REDACTED.to_string(); + if !is_redaction_marker(&identifier.value) { + identifier.value = pseudonyms + .get(&identifier.value) + .cloned() + .unwrap_or_else(|| REDACTED.to_string()); } } if let Some(raw) = row.raw.as_mut() { @@ -72,6 +83,9 @@ fn mask(value: &mut Option) { fn redact_raw_value(value: &mut Value, pseudonyms: &BTreeMap) { match value { Value::String(text) => { + if is_redaction_marker(text) { + return; + } if let Some(pseudonym) = pseudonyms.get(text.as_str()) { *text = pseudonym.clone(); } else if looks_like_user_path(text) { @@ -92,6 +106,22 @@ fn redact_raw_value(value: &mut Value, pseudonyms: &BTreeMap) { } } +/// Is this value one this projection already produced? +/// +/// Such a value is terminal. A later pass must leave it exactly as it is: +/// re-pseudonymizing a pseudonym would renumber it and the projection would +/// stop being idempotent. +fn is_redaction_marker(value: &str) -> bool { + value == REDACTED || pseudonym_pattern().is_match(value) +} + +fn pseudonym_pattern() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^\[redacted-user-\d+\]$").expect("pseudonym pattern must compile") + }) +} + fn looks_like_user_path(value: &str) -> bool { user_profile_path_pattern().is_match(value) } diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs index 5198200c4..b2c559d05 100644 --- a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs @@ -704,6 +704,84 @@ fn package_state_redacted_export_masks_paths_and_user_identifiers_without_mutati assert_eq!(redacted_package_state_export(&safe), safe); } +#[test] +fn package_state_redacted_export_stays_idempotent_beyond_nine_identifiers() { + // Pseudonyms are numbered by sort position, and "[redacted-user-10]" sorts + // before "[redacted-user-1]" because '0' < ']'. So a second pass must treat + // an already-redacted value as terminal. Otherwise ten or more identifiers + // get renumbered and the projection stops being idempotent. Two identifiers + // cannot catch this: single digits sort the same either way. + const IDENTIFIER_COUNT: usize = 12; + + let rows: Vec = (0..IDENTIFIER_COUNT) + .map(|index| { + format!( + r#"{{ + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["currentUser"], + "userIdentifier": {{ + "value": "CONTOSO\\user{index:02}", + "sensitivity": "restricted" + }}, + "app": "companyPortal" + }}"# + ) + }) + .collect(); + + let capture = parse(&format!( + r#"{{ + "schemaVersion": 1, + "capture": {{ + "capturedAtUtc": "2026-07-14T09:41:07.1000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "source": "json", + "scopeCoverage": [ + {{ "scope": "allUsers", "status": "complete", "detail": null }} + ] + }}, + "packages": [{}] + }}"#, + rows.join(",") + )); + + let once = redacted_package_state_export(&capture); + let twice = redacted_package_state_export(&once); + assert_eq!( + twice, once, + "projection must stay idempotent past nine identifiers" + ); + + // Renumbering would still produce distinct labels, so distinctness alone + // does not prove idempotence. Assert it anyway: collapsing two users onto + // one pseudonym would be the worse failure. + let labels: BTreeSet<&str> = once + .packages + .iter() + .filter_map(|row| row.user_identifier.as_ref()) + .map(|identifier| identifier.value.as_str()) + .collect(); + assert_eq!( + labels.len(), + IDENTIFIER_COUNT, + "every identifier must keep a distinct pseudonym" + ); + + let safe_json = serde_json::to_string(&once).expect("projection must serialize"); + assert!( + !safe_json.contains("CONTOSO"), + "redacted export still leaks a domain: {safe_json}" + ); +} + // --------------------------------------------------------------------------- // Fixture layout guard // --------------------------------------------------------------------------- From 92c17045abb11cb4c5e78ea8ca808e353a3338b5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 12:03:59 -0400 Subject: [PATCH 4/6] fix(intune): decide package-state absence per scope, not per app push_absence_findings skipped an app entirely as soon as the capture held any row for it, then emitted a per-scope message. So an app registered only in currentUser, with both currentUser and allUsers enumerated completely, produced no finding at all, even though it really is absent from allUsers. A per-user-only Company Portal registration is a genuine deployment signal and the contract was staying silent about it. Absence is now decided per scope: for each completely enumerated scope, the app is absent when no row for it lists that scope. A row carrying no scope attribution suppresses the claim for that app entirely, because an unattributable row might be the very registration in question, and over-claiming absence is the failure mode this contract exists to prevent. The zero-row case is unchanged. The doc comment also claimed absence was limited to apps the caller asked about, while Company Portal has always been checked unconditionally. Reworded to match what the code does. Reported by GitHub Copilot on PR #395. Verified: package_state_absence_is_decided_per_scope_not_per_app fails against the previous implementation and passes against this one. cargo test --locked 1385 passed / 0 failed in the parser crate and workspace; cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0. Note: src-tauri/tests/cmtlog_parser.rs::header_entry_parsed_correctly flakes independently of this change. TempLogFixture names its temp dir from SystemTime::now().as_nanos(), parallel tests collide on one directory, and Drop removes it out from under the other test. Pre-existing and tracked separately. Refs #367 Co-Authored-By: Claude Opus 5 --- .../company_portal/package_state/findings.rs | 22 ++++- .../company_portal_windows_package_state.rs | 96 +++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs index 119e8fa2f..39a8bc720 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs @@ -404,8 +404,10 @@ fn push_version_mismatch_findings( } } -/// Absence is claimed only for apps the caller asked about, and only against a -/// scope the adapter proved it enumerated completely. +/// Absence is claimed for Company Portal, which is the subject of this +/// contract, plus any app the caller supplied a fact for. It is claimed only +/// against a scope the adapter proved it enumerated completely, and only when +/// the app has no registration attributed to that scope. fn push_absence_findings( capture: &PackageStateCapture, expected: &[ExpectedPackageFact], @@ -429,10 +431,24 @@ fn push_absence_findings( } for app in apps { - if !capture.rows_for_app(&app).is_empty() { + let rows = capture.rows_for_app(&app); + + // A row carrying no scope attribution cannot be placed. Claiming it + // absent from a scope it might belong to would over-claim, so this app + // stays silent until the adapter attributes every row it returned. + if rows.iter().any(|(_, row)| row.scopes.is_empty()) { continue; } + for scope in &complete_scopes { + // Absence is decided per scope, not per app. A registration in one + // completely enumerated scope proves nothing about another: an app + // present only in currentUser really is absent from allUsers, and + // that is a deployment signal worth reporting rather than a reason + // to stay quiet. + if rows.iter().any(|(_, row)| row.scopes.contains(scope)) { + continue; + } findings.push(PackageStateFinding { id: format!( "package-state/absent/{}/{}", diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs index b2c559d05..08069b595 100644 --- a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs @@ -253,6 +253,102 @@ fn package_state_absence_is_not_claimed_from_a_scope_that_was_never_queried() { assert!(absences[0].id.contains("authenticator")); } +#[test] +fn package_state_absence_is_decided_per_scope_not_per_app() { + // Company Portal is registered for the current user only, and BOTH scopes + // were enumerated completely. It is genuinely absent from allUsers, which + // is a real deployment signal. Deciding absence per app rather than per + // scope would see one row, conclude "present", and stay silent. + let capture = parse( + r#"{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:41:07.1000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "source": "json", + "scopeCoverage": [ + { "scope": "currentUser", "status": "complete", "detail": null }, + { "scope": "allUsers", "status": "complete", "detail": null } + ] + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": ["currentUser"], + "app": "companyPortal" + } + ] + }"#, + ); + + let findings = derive_package_state_findings(&capture, &[]); + let absences = of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope, + ); + + assert_eq!( + absences.len(), + 1, + "the completely enumerated allUsers scope must carry an absence claim" + ); + assert_eq!(absences[0].evidence[0].scope, Some(PackageScope::AllUsers)); + assert!(absences[0].id.contains("companyPortal")); +} + +#[test] +fn package_state_absence_stays_silent_when_a_row_carries_no_scope_attribution() { + // The adapter returned a row it could not attribute to a scope. Claiming + // the app absent from allUsers would over-claim, because the unattributed + // row might be exactly that registration. + let capture = parse( + r#"{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:41:07.1000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ] + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "signatureKind": "store", + "status": "ok", + "installState": "installed", + "scopes": [], + "app": "companyPortal" + } + ] + }"#, + ); + + let findings = derive_package_state_findings(&capture, &[]); + assert!( + of_kind( + &findings, + PackageStateFindingKind::PackageAbsentFromCapturedScope, + ) + .is_empty(), + "an unattributed row must not produce an absence claim" + ); +} + // --------------------------------------------------------------------------- // Fixture 4: per-user registration without a raw username // --------------------------------------------------------------------------- From e511b31e2f3df14a69d9c5868797b2462cd7eb09 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 12:11:28 -0400 Subject: [PATCH 5/6] fix(intune): close the CodeRabbit findings on the package-state contract Six issues from the CodeRabbit review of PR #395. Each was verified against the code before being accepted, and each fix carries a test that fails against the previous implementation. Privacy. Finding messages interpolated the adapter's error message and scope-coverage detail, both classified sensitive in the capture schema. PackageStateFinding is a separate type that redacted_package_state_export never sees, so a redacted export still carried whatever those fields held: the access-denied fixture's message contains a real-looking profile path. Findings now carry the stable error code only, and the free text stays in the capture where redaction covers it. LegacyRefusal.detail quoted the offending source line for the same reason and now reports its position and length instead. Correctness. PackageRow uses #[serde(default)], so a row omitting `status` deserialized to an empty Unknown and was reported as an Error-severity package health problem: a missing field became an invented fault. An unreported status is now silent. Version-mismatch finding ids omitted the fact source, so two facts naming the same app and expected version from different sources collided on one id with two different messages; the source is now part of the id. Legacy import. A missing blank separator merged two Format-List records into one, and the import reported success carrying the first record's Name with both records' fields mixed. This module refuses wrapped and truncated input precisely so a bad read never looks like a good one, so a repeated identifying label now refuses with AmbiguousRecord. Collector. Access denial was classified by matching English words in the error message, so a localized Windows host reported accessDenied as failed. The catch block now checks FullyQualifiedErrorId and the E_ACCESSDENIED HResult before falling back to message text. The profile test pinned only the -Depth flag and now pins -Depth 6, since a lower value silently truncates scopeCoverage and installLocation. Windows adapter test. It asserted a successful exit code before parsing, which contradicted its own acceptance of Failed and AccessDenied: those can only be observed when the script exits 0 and reports the outcome inside the JSON. It now parses first and treats a non-zero exit with a parseable capture as valid. Stdout was decoded with from_utf8_lossy, which turns Windows-1252 bytes in a publisher string or install path into U+FFFD; it now uses detect_encoding plus decode_bytes, the UTF-8 with Windows-1252 fallback CLAUDE.md requires. Verified: all five new parser tests fail against the previous implementation and pass against this one. cargo test --locked 1390 passed / 0 failed (1385 before); cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0. The edited Windows-gated test still cannot run on this macOS host. It was type-checked by temporarily retargeting its cfg to macos and running cargo check and clippy with --all-targets, then restoring the Windows gate. Windows CI remains its first real execution. Refs #367 Co-Authored-By: Claude Opus 5 --- .../src/collector/profile.rs | 4 +- .../src/collector/profile_data.json | 2 +- .../company_portal/package_state/findings.rs | 37 ++-- .../company_portal/package_state/legacy.rs | 32 +++- .../company_portal_windows_package_state.rs | 170 ++++++++++++++++++ src-tauri/src/collector/mod.rs | 31 +++- 6 files changed, 249 insertions(+), 27 deletions(-) diff --git a/crates/cmtraceopen-parser/src/collector/profile.rs b/crates/cmtraceopen-parser/src/collector/profile.rs index f40607909..ff109254f 100644 --- a/crates/cmtraceopen-parser/src/collector/profile.rs +++ b/crates/cmtraceopen-parser/src/collector/profile.rs @@ -177,7 +177,9 @@ mod tests { "scopeCoverage", "accessDenied", "-Compress", - "-Depth", + // Pin the depth, not just the flag. A lower value would silently + // truncate capture.scopeCoverage[] and packages[].installLocation. + "-Depth 6", ] { assert!( command.contains(required), diff --git a/crates/cmtraceopen-parser/src/collector/profile_data.json b/crates/cmtraceopen-parser/src/collector/profile_data.json index f74cbcc4f..fccba2072 100644 --- a/crates/cmtraceopen-parser/src/collector/profile_data.json +++ b/crates/cmtraceopen-parser/src/collector/profile_data.json @@ -1301,7 +1301,7 @@ "family": "general", "parseHints": ["json", "package-state"], "command": "powershell.exe", - "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"], + "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or ([string]$_.FullyQualifiedErrorId) -match 'UnauthorizedAccess|AccessDenied' -or (($_.Exception.HResult -band 0xFFFFFFFF) -eq 0x80070005) -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"], "fileName": "appx-intune-packages.json", "timeoutSecs": 30, "notes": "Company Portal and Authenticator AppX package state as the versioned JSON capture schema (schemaVersion 1) consumed by intune::portal::windows::company_portal::package_state. Emits commandStatus and per-scope coverage so a denied -AllUsers query is reported as accessDenied/denied instead of an empty success." diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs index 39a8bc720..17c66b50d 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs @@ -228,14 +228,18 @@ fn push_coverage_findings(capture: &PackageStateCapture, findings: &mut Vec PackageStateFindingSeverity::Warning, }; + // The adapter's error message is classified sensitive: it can name a + // profile path or an account. Findings are a separate type that the + // redaction projection never sees, so interpolating that text here + // would smuggle it past redaction. The stable error code carries no + // identity and is enough to act on; the message stays in the capture, + // where redaction covers it. let detail = capture .capture .error .as_ref() - .map(|error| match &error.code { - Some(code) => format!(" ({code}: {})", error.message), - None => format!(" ({})", error.message), - }) + .and_then(|error| error.code.as_ref()) + .map(|code| format!(" ({code})")) .unwrap_or_default(); findings.push(PackageStateFinding { id: format!( @@ -266,11 +270,9 @@ fn push_coverage_findings(capture: &PackageStateCapture, findings: &mut Vec PackageStateFindingSeverity::Warning, }; - let detail = coverage - .detail - .as_ref() - .map(|detail| format!(" ({detail})")) - .unwrap_or_default(); + // Same reasoning as the command error above: adapter-supplied detail + // text is free-form and can carry a path or an account, so it stays in + // the capture rather than being copied into a finding. findings.push(PackageStateFinding { id: format!( "package-state/incomplete-query/scope/{}/{}", @@ -281,7 +283,7 @@ fn push_coverage_findings(capture: &PackageStateCapture, findings: &mut Vec Result)> { + // `get` returns the first match and unknown labels overwrite in `raw`, + // so two records that lost their blank separator would silently + // collapse into one row carrying the first Name and a mixture of both + // records' fields. This module refuses wrapped and truncated input + // precisely so a bad read never looks like a good one; a repeated + // identifying label is the same class of problem. + let name_count = self + .fields + .iter() + .filter(|(label, _)| label.eq_ignore_ascii_case("Name")) + .count(); + if name_count > 1 { + return Err(( + LegacyRefusalReason::AmbiguousRecord, + format!( + "Record starting at line {} repeats the 'Name' label {name_count} times, \ + which indicates two records merged by a missing blank separator.", + self.first_line + ), + Some(self.first_line), + )); + } + let name = self.get("Name").filter(|value| !value.is_empty()).ok_or(( LegacyRefusalReason::IncompleteRecord, format!( diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs index 08069b595..ecdb9fe7e 100644 --- a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs @@ -878,6 +878,176 @@ fn package_state_redacted_export_stays_idempotent_beyond_nine_identifiers() { ); } +#[test] +fn package_state_findings_never_carry_adapter_supplied_free_text() { + // Findings are a separate type that the redaction projection does not + // cover. Interpolating the adapter's error message or a scope-coverage + // detail into a finding message would carry a profile path or an account + // name straight past redaction. The access-denied fixture's error message + // contains a real-looking profile path, so it is the sharp case. + for source in [ACCESS_DENIED, COMMAND_FAILURE] { + let capture = parse(source); + let findings = derive_package_state_findings(&capture, &[]); + let messages = findings + .iter() + .map(|finding| finding.message.as_str()) + .collect::>() + .join("\n"); + + if let Some(error) = capture.capture.error.as_ref() { + assert!( + !messages.contains(error.message.as_str()), + "finding messages leak the adapter error message: {messages}" + ); + } + for coverage in &capture.capture.scope_coverage { + if let Some(detail) = coverage.detail.as_ref() { + assert!( + !messages.contains(detail.as_str()), + "finding messages leak scope-coverage detail: {messages}" + ); + } + } + assert!( + !messages.contains("jrivera") && !messages.contains(r"C:\Users"), + "finding messages leak identity or a profile path: {messages}" + ); + } +} + +#[test] +fn package_state_absent_status_field_is_not_a_health_problem() { + // PackageRow carries #[serde(default)], so a row that omits `status` + // deserializes to an empty Unknown. Reporting that as an Error would + // invent a package fault out of a field the adapter never sent. + let capture = parse( + r#"{ + "schemaVersion": 1, + "capture": { + "capturedAtUtc": "2026-07-14T09:41:07.1000000Z", + "adapterVersion": "cmtraceopen-collector-appx/1", + "commandStatus": "completed", + "source": "json", + "scopeCoverage": [ + { "scope": "allUsers", "status": "complete", "detail": null } + ] + }, + "packages": [ + { + "name": "Microsoft.CompanyPortal", + "familyName": "Microsoft.CompanyPortal_8wekyb3d8bbwe", + "fullName": "Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe", + "version": "11.2.401.0", + "architecture": "x64", + "signatureKind": "store", + "installState": "installed", + "scopes": ["allUsers"], + "app": "companyPortal" + } + ] + }"#, + ); + + assert_eq!( + capture.packages[0].status, + PackageStatus::Unknown(String::new()), + "an omitted status must round-trip as an empty Unknown" + ); + assert!( + of_kind( + &derive_package_state_findings(&capture, &[]), + PackageStateFindingKind::PackageStatusProblem, + ) + .is_empty(), + "an unreported status must not be reported as a package health problem" + ); +} + +#[test] +fn package_state_version_mismatch_ids_stay_distinct_across_sources() { + // Two facts can name the same app and the same expected version but come + // from different sources. Both match the row, so the id must carry the + // source or the two findings collide on one key with different messages. + let capture = parse(INSTALLED_COMPANY_PORTAL); + let expected = [ + ExpectedPackageFact { + app: PortalApp::CompanyPortal, + family_name: None, + expected_version: "11.9.999.0".to_string(), + source: "Intune app assignment".to_string(), + }, + ExpectedPackageFact { + app: PortalApp::CompanyPortal, + family_name: None, + expected_version: "11.9.999.0".to_string(), + source: "Store catalog".to_string(), + }, + ]; + + let findings = derive_package_state_findings(&capture, &expected); + let mismatches = of_kind(&findings, PackageStateFindingKind::VersionMismatch); + assert_eq!(mismatches.len(), 2); + + let ids: BTreeSet<&str> = mismatches.iter().map(|f| f.id.as_str()).collect(); + assert_eq!( + ids.len(), + 2, + "version-mismatch ids must stay distinct per source: {ids:?}" + ); +} + +#[test] +fn package_state_legacy_import_refuses_records_merged_by_a_missing_separator() { + // Without the blank line between records, both collapse into one + // LegacyRecord: `get` returns the first Name and unknown labels overwrite. + // That would report a single package built from two, and look successful. + let merged = "\ +Name : Microsoft.CompanyPortal +Version : 11.2.401.0 +PackageFullName : Microsoft.CompanyPortal_11.2.401.0_x64__8wekyb3d8bbwe +Status : Ok +SignatureKind : Store +Name : Microsoft.AAD.BrokerPlugin.Authenticator +Version : 6.2408.1 +PackageFullName : Microsoft.AAD.BrokerPlugin.Authenticator_6.2408.1_neutral__8wekyb3d8bbwe +Status : Ok +SignatureKind : System +"; + + let outcome = import_legacy_format_list(merged, english_legacy_metadata()); + assert_eq!( + outcome.refusal().map(|refusal| refusal.reason), + Some(LegacyRefusalReason::AmbiguousRecord), + "a record repeating the Name label must refuse rather than merge" + ); + assert!(outcome.imported().is_none()); +} + +#[test] +fn package_state_legacy_refusal_detail_never_quotes_the_source_line() { + // The refusal detail is free text nothing redacts, and a Format-List line + // can carry an install path or an account. Locate the line, never quote it. + let outcome = import_legacy_format_list(LEGACY_WRAPPED, english_legacy_metadata()); + let refusal = outcome.refusal().expect("wrapped sample must refuse"); + let offending = LEGACY_WRAPPED + .lines() + .nth(refusal.line_number.expect("refusal must locate the line") - 1) + .expect("refusal must point at a real line") + .trim(); + + assert!(!offending.is_empty()); + assert!( + !refusal.detail.contains(offending), + "refusal detail quotes the source line verbatim: {}", + refusal.detail + ); + assert!( + refusal.detail.contains("characters"), + "refusal detail should describe the line by length: {}", + refusal.detail + ); +} + // --------------------------------------------------------------------------- // Fixture layout guard // --------------------------------------------------------------------------- diff --git a/src-tauri/src/collector/mod.rs b/src-tauri/src/collector/mod.rs index 82866a131..a92ed351a 100644 --- a/src-tauri/src/collector/mod.rs +++ b/src-tauri/src/collector/mod.rs @@ -24,6 +24,7 @@ mod appx_adapter_windows_tests { parse_package_state_capture, PackageCaptureCommandStatus, PackageCaptureSource, COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION, }; + use cmtraceopen_parser::parser::{decode_bytes, detect_encoding}; use super::types::CollectionProfile; @@ -40,16 +41,28 @@ mod appx_adapter_windows_tests { .args(&item.arguments) .output() .expect("AppX adapter command must run"); - assert!( - output.status.success(), - "adapter exited with {:?}: {}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let capture = parse_package_state_capture(stdout.trim()) - .expect("adapter output must parse as a package-state capture"); + // PowerShell writes stdout in the active console code page, which is + // Windows-1252 on many hosts, so a publisher string or install path can + // carry non-UTF-8 bytes. from_utf8_lossy would turn those into U+FFFD + // and fail the parse or corrupt the values. + let stdout = decode_bytes(&output.stdout, detect_encoding(&output.stdout)) + .expect("adapter stdout must decode"); + + // The exit code is deliberately not asserted first. The whole point of + // the contract is that a denied or failed query is reported inside the + // JSON, so a non-zero exit accompanied by a parseable capture is a + // correctly reported outcome. Only an unparseable capture is a failure, + // and then the exit code and stderr are the useful diagnostics. + let capture = parse_package_state_capture(stdout.trim()).unwrap_or_else(|error| { + panic!( + "adapter output must parse as a package-state capture \ + (exit {:?}, error {error:?}): {}", + output.status.code(), + decode_bytes(&output.stderr, detect_encoding(&output.stderr)) + .unwrap_or_else(|_| "".to_string()) + ) + }); assert_eq!( capture.schema_version, From 2b9d3d5afe0346246630be7eab26553116a19671 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 12:19:08 -0400 Subject: [PATCH 6/6] refactor(intune): act on the classification and stop the parser panicking Four CodeRabbit nitpicks on the package-state contract, each verified before being accepted. One stale nitpick about the push_absence_findings doc comment was already fixed in 92c17045 and is skipped. camel_case_enum called expect on the deserialize result. The raw-preserving enums it is used with accept any string, so it cannot fail today, but the bound was only Deserialize: a later call with a strict enum would panic inside a pure parser on untrusted text. It now requires Default and falls back. Default is implemented for the four package enums as an empty Unknown, which is what PackageRow::default already constructed by hand. The redaction projection masked and pseudonymized on presence alone, ignoring the sensitivity field that exists precisely to classify these values. Every value wired up today is sensitive or restricted, so behavior is unchanged, but a producer marking a value public would have had it discarded anyway. That is the over-redaction this module documents itself as avoiding. Masking is now gated on the classification. Two test-quality fixes. The privacy test's assertions were all conditional, so a fixture that stopped carrying sensitive text would leave the test passing while proving nothing; it now asserts the fixture still carries identity and a profile path before asserting no finding repeats them. The ordering assertion claimed findings are emitted most-severe first, but sort_findings orders by kind rank and id; the message now says what the code does. Added an inline models test comparing the serialized PackageRow shape against KNOWN_PACKAGE_ROW_FIELDS. That list is maintained by hand and decides what gets folded into `raw`, so a field added to the struct but forgotten there would appear twice in one capture. Verified: cargo test --locked 1391 passed / 0 failed (1390 before); cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0. Refs #367 Co-Authored-By: Claude Opus 5 --- .../company_portal/package_state/legacy.rs | 11 +++- .../company_portal/package_state/models.rs | 64 +++++++++++++++++-- .../company_portal/package_state/redaction.rs | 19 +++++- .../company_portal_windows_package_state.rs | 45 +++++++++++-- 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs index b5bbdd66f..3e1eb5232 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs @@ -384,14 +384,19 @@ fn derive_from_full_name(full_name: &str) -> (Option, Option Deserialize<'de>>(value: &str) -> T { +/// Decode a display-cased Format-List token into a wire enum. +/// +/// The raw-preserving enums this is called with accept any string, so decoding +/// cannot fail today. The bound is only `Deserialize`, though, so a future call +/// with a strict enum would otherwise panic inside a pure parser on untrusted +/// text. Fall back to the type's default instead of panicking. +fn camel_case_enum Deserialize<'de> + Default>(value: &str) -> T { let mut chars = value.chars(); let camel = match chars.next() { Some(first) => first.to_lowercase().collect::() + chars.as_str(), None => String::new(), }; - serde_json::from_value(Value::String(camel)) - .expect("raw-preserving enums accept any string value") + serde_json::from_value(Value::String(camel)).unwrap_or_default() } fn classify_app(name: &str) -> PortalApp { diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs index 23cdb1e73..63da65f39 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs @@ -271,6 +271,32 @@ pub struct PackageRow { pub raw: Option, } +// An unreported value is an empty Unknown rather than a guessed variant. These +// impls give callers a non-panicking fallback when a value cannot be decoded. +impl Default for PackageArchitecture { + fn default() -> Self { + Self::Unknown(String::new()) + } +} + +impl Default for PackageSignatureKind { + fn default() -> Self { + Self::Unknown(String::new()) + } +} + +impl Default for PackageStatus { + fn default() -> Self { + Self::Unknown(String::new()) + } +} + +impl Default for PackageInstallState { + fn default() -> Self { + Self::Unknown(String::new()) + } +} + impl Default for PackageRow { fn default() -> Self { Self { @@ -278,11 +304,11 @@ impl Default for PackageRow { family_name: String::new(), full_name: String::new(), version: String::new(), - architecture: PackageArchitecture::Unknown(String::new()), + architecture: PackageArchitecture::default(), publisher: None, - signature_kind: PackageSignatureKind::Unknown(String::new()), - status: PackageStatus::Unknown(String::new()), - install_state: PackageInstallState::Unknown(String::new()), + signature_kind: PackageSignatureKind::default(), + status: PackageStatus::default(), + install_state: PackageInstallState::default(), scopes: Vec::new(), user_registration_count: None, user_identifier: None, @@ -378,3 +404,33 @@ pub enum PackageStateError { #[error("package state capture body does not match schema version {version}: {detail}")] InvalidBody { version: u32, detail: String }, } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn known_package_row_fields_match_the_struct() { + // KNOWN_PACKAGE_ROW_FIELDS duplicates the PackageRow field names by + // hand, and it decides what gets folded into `raw`. A field added to + // the struct but forgotten here would parse into its typed field AND be + // copied into `raw`, so the same value would appear twice in one + // capture. Compare against the serialized shape so the list cannot + // drift. + let serialized = + serde_json::to_value(PackageRow::default()).expect("a package row must serialize"); + let actual: BTreeSet<&str> = serialized + .as_object() + .expect("a package row serializes to an object") + .keys() + .map(String::as_str) + .collect(); + let declared: BTreeSet<&str> = KNOWN_PACKAGE_ROW_FIELDS.iter().copied().collect(); + + assert_eq!( + actual, declared, + "KNOWN_PACKAGE_ROW_FIELDS has drifted from PackageRow" + ); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs index 6bb50bd77..8d97e77d5 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs @@ -12,7 +12,7 @@ use std::sync::OnceLock; use regex::Regex; use serde_json::Value; -use super::models::{PackageStateCapture, PackageStateClassifiedString}; +use super::models::{PackageStateCapture, PackageStateClassifiedString, PackageStateSensitivity}; const REDACTED: &str = "[redacted]"; @@ -37,6 +37,7 @@ pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageSt .packages .iter() .filter_map(|row| row.user_identifier.as_ref()) + .filter(|identifier| carries_identity(identifier)) .map(|identifier| identifier.value.clone()) .filter(|value| !is_redaction_marker(value)) .collect(); @@ -53,7 +54,7 @@ pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageSt for row in &mut safe.packages { mask(&mut row.install_location); if let Some(identifier) = row.user_identifier.as_mut() { - if !is_redaction_marker(&identifier.value) { + if carries_identity(identifier) && !is_redaction_marker(&identifier.value) { identifier.value = pseudonyms .get(&identifier.value) .cloned() @@ -74,10 +75,22 @@ pub fn redacted_package_state_export(capture: &PackageStateCapture) -> PackageSt fn mask(value: &mut Option) { if let Some(classified) = value.as_mut() { - classified.value = REDACTED.to_string(); + if carries_identity(classified) { + classified.value = REDACTED.to_string(); + } } } +/// Does this classified value need masking? +/// +/// The classification exists to be acted on. Masking purely on presence would +/// discard a value a producer had explicitly marked public, which is the +/// over-redaction this module exists to avoid: it destroys the diagnostic value +/// the export is for. +fn carries_identity(value: &PackageStateClassifiedString) -> bool { + value.sensitivity != PackageStateSensitivity::Public +} + /// Walk a preserved raw blob and mask the two things it can plausibly leak: /// user-profile paths and identifiers we already pseudonymized elsewhere. fn redact_raw_value(value: &mut Value, pseudonyms: &BTreeMap) { diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs index ecdb9fe7e..033ca7a58 100644 --- a/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs @@ -610,7 +610,14 @@ fn package_state_finding_order_is_stable_across_input_permutations() { let ranks: Vec<_> = first.iter().map(|finding| finding.kind).collect(); let mut sorted = ranks.clone(); sorted.sort(); - assert_eq!(ranks, sorted, "findings must be emitted most-severe first"); + // sort_findings orders by PackageStateFindingKind::rank() and then by id, + // not by severity. Severity happens to order Info < Warning < Error, so + // saying "most-severe first" here would describe a different contract than + // the one actually implemented. + assert_eq!( + ranks, sorted, + "findings must be emitted in stable kind order" + ); } // --------------------------------------------------------------------------- @@ -894,6 +901,18 @@ fn package_state_findings_never_carry_adapter_supplied_free_text() { .collect::>() .join("\n"); + // Precondition. Every leak assertion below is conditional, so a fixture + // that stopped carrying sensitive text would make this test pass while + // proving nothing. + assert!( + capture.capture.error.is_some(), + "fixture must supply an adapter error message for this test to mean anything" + ); + assert!( + !messages.is_empty(), + "fixture must produce findings for this test to mean anything" + ); + if let Some(error) = capture.capture.error.as_ref() { assert!( !messages.contains(error.message.as_str()), @@ -908,11 +927,27 @@ fn package_state_findings_never_carry_adapter_supplied_free_text() { ); } } - assert!( - !messages.contains("jrivera") && !messages.contains(r"C:\Users"), - "finding messages leak identity or a profile path: {messages}" - ); } + + // The access-denied fixture is the sharp case: its error message names a + // real-looking profile path. Assert the fixture still carries it, then + // assert no finding repeats it. + let access_denied = parse(ACCESS_DENIED); + let raw = serde_json::to_string(&access_denied).expect("capture must serialize"); + assert!( + raw.contains("jrivera") && raw.contains(r"C:\\Users"), + "the access-denied fixture must still carry identity and a profile path" + ); + + let messages = derive_package_state_findings(&access_denied, &[]) + .iter() + .map(|finding| finding.message.clone()) + .collect::>() + .join("\n"); + assert!( + !messages.contains("jrivera") && !messages.contains(r"C:\Users"), + "finding messages leak identity or a profile path: {messages}" + ); } #[test]