From 14ace9d00f26465020d7819d1775cdb4d2cb407b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 10:52:23 -0400 Subject: [PATCH 1/6] feat(collection): capture LocaleMetaData so exported channels are self-describing wevtutil export-log emits only the binary records. Rendering an event description then requires the originating provider to be registered on whatever machine opens the file, which is never true for an analyst workstation and is impossible on macOS or Linux. Every bundle collected without that metadata is permanently unresolvable once the source machine is rebuilt, so this is captured now rather than after the reader exists. After each successful export the collector runs wevtutil archive-log, which writes a LocaleMetaData\_.MTA sidecar carrying the provider's message strings. Each sidecar is recorded in the manifest as an event-log-metadata artifact with its own hash. Archive failures degrade to an explicit coverage gap rather than silence, leaving the evtx collected. Locale is left to the collecting machine's default so the strings match what the operator saw. -SkipLocaleMetadata opts out. Measured on a Windows 11 x64 lab host: +241 KB on a zipped seven-channel bundle (411,890 to 653,155 bytes). This also fixes two pre-existing defects that stopped the collector running at all against the shipped profile, on both hosts: - ConvertFrom-Json was called with -Depth, which does not exist in Windows PowerShell 5.1 and fails parameter binding. That host is where the collector is deployed. - The optional-array check read values through Get-ObjectPropertyValue, which returns through the pipeline. That enumerates a single-element array such as arguments: ["/status"] down to a bare string, so the shipped profile was rejected as malformed. The check now reads the raw property, matching what Assert-ProfileRequiredArray already does. The existing suite missed both because its fixture profile only used empty arrays, which enumerate to nothing and read as absent. Verified end to end on Windows 11 x64 under both Windows PowerShell 5.1 and pwsh 7.6: 9 channels exported, 9 MTA sidecars, 9 manifest records. Pester 14/14. Co-Authored-By: Claude Opus 5 --- .../Invoke-CmtraceEvidenceCollection.ps1 | 119 +++++++++++++++++- ...Invoke-CmtraceEvidenceCollection.Tests.ps1 | 67 +++++++++- 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index aa4311f21..3d979cc77 100644 --- a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -9,7 +9,8 @@ param( [string]$OperatorName = 'SYSTEM', [string]$OperatorTeam = 'Intune', [string]$OperatorContact = '', - [switch]$LocalOnly + [switch]$LocalOnly, + [switch]$SkipLocaleMetadata ) Set-StrictMode -Version Latest @@ -114,6 +115,30 @@ function Join-RelativePath { return (($Left.TrimEnd('/')) + '/' + ($Right.TrimStart('/'))) } +function Get-LocaleMetadataRelativePath { + <# + .SYNOPSIS + Returns the manifest-relative LocaleMetaData folder that sits beside an exported channel. + + .DESCRIPTION + wevtutil.exe archive-log writes its .MTA sidecars into a LocaleMetaData subdirectory + created next to the exported .evtx, so the manifest path has to mirror that layout. + #> + param( + [Parameter(Mandatory = $true)] + [string]$EvtxRelativePath + ) + + $normalizedPath = $EvtxRelativePath -replace '\\', '/' + $lastSeparatorIndex = $normalizedPath.LastIndexOf('/') + + if ($lastSeparatorIndex -lt 0) { + return 'LocaleMetaData' + } + + return (Join-RelativePath -Left $normalizedPath.Substring(0, $lastSeparatorIndex) -Right 'LocaleMetaData') +} + function ConvertTo-PhysicalPath { param( [Parameter(Mandatory = $true)] @@ -223,6 +248,10 @@ function Get-ObjectPropertyValue { [object]$DefaultValue = $null ) + # Returns are deliberately left enumerating. Callers such as Assert-CollectorProfileShape + # collect this with @(...), which would nest the value one level deep if an array were + # returned as a single pipeline item. Callers that must distinguish an array from a scalar + # read PSObject.Properties directly instead of going through here. if ($null -eq $InputObject) { return $DefaultValue } @@ -355,8 +384,15 @@ function Assert-CollectorProfileShape { $artifactIds.Add($artifactId, $itemContext) foreach ($propertyName in @($sectionDefinition.optionalArrays)) { - $propertyValue = Get-ObjectPropertyValue -InputObject $item -Name $propertyName - if ($null -ne $propertyValue -and -not (Test-ArrayValue -Value $propertyValue)) { + # Read the raw property rather than using Get-ObjectPropertyValue: that helper + # returns through the pipeline, which enumerates a single-element array such as + # arguments: ["/status"] down to a bare string and fails this check spuriously. + $property = $item.PSObject.Properties[$propertyName] + if ($null -eq $property -or $null -eq $property.Value) { + continue + } + + if (-not (Test-ArrayValue -Value $property.Value)) { throw ('Collector profile is invalid: {0}. {1}.{2} must be an array when present.' -f $Path, $itemContext, $propertyName) } } @@ -386,7 +422,9 @@ function Read-CollectorProfile { } try { - $collectorProfile = $rawProfile | ConvertFrom-Json -Depth 20 -ErrorAction Stop + # No -Depth here: Windows PowerShell 5.1's ConvertFrom-Json has no such parameter and + # fails parameter binding, which is the host this collector is deployed under. + $collectorProfile = $rawProfile | ConvertFrom-Json -ErrorAction Stop } catch { throw ('Collector profile contains invalid JSON: {0}. Error: {1}' -f $Path, $_.Exception.Message) @@ -706,6 +744,71 @@ function Test-EventChannelExists { } } +function Export-EventChannelLocaleMetadata { + <# + .SYNOPSIS + Archives an already-exported channel so its message strings travel with the bundle. + + .DESCRIPTION + wevtutil.exe export-log emits only the binary records. Rendering an event description + then requires the originating provider to be registered on whatever machine opens the + file, which is never true for an analyst workstation and is impossible on macOS or Linux. + wevtutil.exe archive-log writes a LocaleMetaData\_.MTA sidecar carrying the + provider's message strings, making the export self-describing. + + The locale is deliberately left to the collecting machine's default so the captured + strings match what the operator saw on that endpoint. The emitted LCID is recorded in + the artifact notes rather than predicted, because the file name depends on it. + #> + param( + [Parameter(Mandatory = $true)] + [string]$EvtxPath, + [Parameter(Mandatory = $true)] + [string]$EvtxRelativePath, + [Parameter(Mandatory = $true)] + [string]$Family, + [Parameter(Mandatory = $true)] + [string]$Channel, + [Parameter(Mandatory = $true)] + [System.Collections.IList]$ObservedGaps + ) + + $records = New-Object 'System.Collections.Generic.List[object]' + $metadataRelativeFolder = Get-LocaleMetadataRelativePath -EvtxRelativePath $EvtxRelativePath + $metadataFolder = Join-Path (Split-Path -Parent $EvtxPath) 'LocaleMetaData' + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($EvtxPath) + + & wevtutil.exe al $EvtxPath | Out-Null + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0) { + $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } + + $metadataFiles = @() + if (Test-Path -LiteralPath $metadataFolder) { + $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction SilentlyContinue) + } + + if ($metadataFiles.Count -eq 0) { + $notes = 'wevtutil.exe al reported success but produced no .MTA sidecar. Event descriptions will not resolve away from this machine.' + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'missing' -Origin $Channel -Reason $notes + return $records + } + + foreach ($metadataFile in $metadataFiles) { + $relativePath = Join-RelativePath -Left $metadataRelativeFolder -Right $metadataFile.Name + $notes = 'Locale metadata for {0}, enabling offline event description rendering.' -f $Channel + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $relativePath -OriginPath $Channel -Status 'collected' -ParseHints @('mta') -FilePath $metadataFile.FullName -Notes $notes)) + } + + return $records +} + function Get-RedactedUploadUrl { param( [AllowEmptyString()] @@ -934,6 +1037,14 @@ try { } $artifacts.Add($artifact) + + if ($artifact.status -ne 'collected' -or $SkipLocaleMetadata) { + continue + } + + foreach ($metadataArtifact in (Export-EventChannelLocaleMetadata -EvtxPath $destinationPath -EvtxRelativePath $relativePath -Family $eventItem.family -Channel $eventItem.channel -ObservedGaps $observedGaps)) { + $artifacts.Add($metadataArtifact) + } } Write-Step 'Collecting exported file artifacts' diff --git a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 index 818311ee3..26690f09e 100644 --- a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 +++ b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 @@ -19,7 +19,9 @@ BeforeAll { 'Test-ArrayValue', 'Assert-ProfileRequiredString', 'Assert-ProfileRequiredArray', - 'Assert-CollectorProfileShape' + 'Assert-CollectorProfileShape', + 'Join-RelativePath', + 'Get-LocaleMetadataRelativePath' ) foreach ($functionName in $functionNames) { $definition = $ast.FindAll( @@ -101,6 +103,69 @@ Describe 'Intune evidence profile contracts' { } } +Describe 'Optional array validation' { + It 'accepts a single-element optional array such as arguments: ["/status"]' { + $profile = New-TestCollectorProfile + $profile.commands[0].arguments = @('/status') + + { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + Should -Not -Throw + } + + It 'still accepts an empty optional array' { + $profile = New-TestCollectorProfile + $profile.commands[0].arguments = @() + + { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + Should -Not -Throw + } + + It 'still rejects a scalar where an array is required' { + $profile = New-TestCollectorProfile + $profile.commands[0].arguments = '/status' + + { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + Should -Throw -ExpectedMessage '*commands[[]0[]].arguments must be an array when present*' + } +} + +Describe 'Read-CollectorProfile host compatibility' { + It 'does not pass -Depth to ConvertFrom-Json, which Windows PowerShell 5.1 rejects' { + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + $collectorText | Should -Not -Match 'ConvertFrom-Json[^\r\n]*-Depth' + } + + It 'accepts the shipped profile, including its single-element argument arrays' { + $shippedProfile = Get-Content -LiteralPath $stagedProfilePath -Raw | ConvertFrom-Json + + { Assert-CollectorProfileShape -CollectorProfile $shippedProfile -Path $stagedProfilePath } | + Should -Not -Throw + } +} + +Describe 'Get-LocaleMetadataRelativePath' { + It 'places LocaleMetaData beside the exported channel' { + Get-LocaleMetadataRelativePath -EvtxRelativePath 'evidence/event-logs/device-management-admin.evtx' | + Should -BeExactly 'evidence/event-logs/LocaleMetaData' + } + + It 'handles a channel exported at the bundle root' { + Get-LocaleMetadataRelativePath -EvtxRelativePath 'autopilot.evtx' | + Should -BeExactly 'LocaleMetaData' + } + + It 'normalizes backslash separators to the manifest convention' { + Get-LocaleMetadataRelativePath -EvtxRelativePath 'evidence\event-logs\aad-operational.evtx' | + Should -BeExactly 'evidence/event-logs/LocaleMetaData' + } + + It 'does not depend on the file extension' { + Get-LocaleMetadataRelativePath -EvtxRelativePath 'evidence/event-logs/no-extension' | + Should -BeExactly 'evidence/event-logs/LocaleMetaData' + } +} + Describe 'Assert-CollectorProfileShape' { It 'accepts unique artifact IDs across all sections' { $profile = New-TestCollectorProfile From d3aad70e02a0adc1844db26a34b0637f2ce5d593 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 12:05:41 -0400 Subject: [PATCH 2/6] fix(collection): sync the reference collector copy crates/cmtraceopen-parser asserts scripts/collection and references/collection hold byte-identical collector scripts. The previous commit changed only the staged copy, so cross_profile_collectors_preserve_command_parse_hints failed with "collection scripts drifted". Co-Authored-By: Claude Opus 5 --- .../Invoke-CmtraceEvidenceCollection.ps1 | 119 +++++++++++++++++- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index aa4311f21..3d979cc77 100644 --- a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -9,7 +9,8 @@ param( [string]$OperatorName = 'SYSTEM', [string]$OperatorTeam = 'Intune', [string]$OperatorContact = '', - [switch]$LocalOnly + [switch]$LocalOnly, + [switch]$SkipLocaleMetadata ) Set-StrictMode -Version Latest @@ -114,6 +115,30 @@ function Join-RelativePath { return (($Left.TrimEnd('/')) + '/' + ($Right.TrimStart('/'))) } +function Get-LocaleMetadataRelativePath { + <# + .SYNOPSIS + Returns the manifest-relative LocaleMetaData folder that sits beside an exported channel. + + .DESCRIPTION + wevtutil.exe archive-log writes its .MTA sidecars into a LocaleMetaData subdirectory + created next to the exported .evtx, so the manifest path has to mirror that layout. + #> + param( + [Parameter(Mandatory = $true)] + [string]$EvtxRelativePath + ) + + $normalizedPath = $EvtxRelativePath -replace '\\', '/' + $lastSeparatorIndex = $normalizedPath.LastIndexOf('/') + + if ($lastSeparatorIndex -lt 0) { + return 'LocaleMetaData' + } + + return (Join-RelativePath -Left $normalizedPath.Substring(0, $lastSeparatorIndex) -Right 'LocaleMetaData') +} + function ConvertTo-PhysicalPath { param( [Parameter(Mandatory = $true)] @@ -223,6 +248,10 @@ function Get-ObjectPropertyValue { [object]$DefaultValue = $null ) + # Returns are deliberately left enumerating. Callers such as Assert-CollectorProfileShape + # collect this with @(...), which would nest the value one level deep if an array were + # returned as a single pipeline item. Callers that must distinguish an array from a scalar + # read PSObject.Properties directly instead of going through here. if ($null -eq $InputObject) { return $DefaultValue } @@ -355,8 +384,15 @@ function Assert-CollectorProfileShape { $artifactIds.Add($artifactId, $itemContext) foreach ($propertyName in @($sectionDefinition.optionalArrays)) { - $propertyValue = Get-ObjectPropertyValue -InputObject $item -Name $propertyName - if ($null -ne $propertyValue -and -not (Test-ArrayValue -Value $propertyValue)) { + # Read the raw property rather than using Get-ObjectPropertyValue: that helper + # returns through the pipeline, which enumerates a single-element array such as + # arguments: ["/status"] down to a bare string and fails this check spuriously. + $property = $item.PSObject.Properties[$propertyName] + if ($null -eq $property -or $null -eq $property.Value) { + continue + } + + if (-not (Test-ArrayValue -Value $property.Value)) { throw ('Collector profile is invalid: {0}. {1}.{2} must be an array when present.' -f $Path, $itemContext, $propertyName) } } @@ -386,7 +422,9 @@ function Read-CollectorProfile { } try { - $collectorProfile = $rawProfile | ConvertFrom-Json -Depth 20 -ErrorAction Stop + # No -Depth here: Windows PowerShell 5.1's ConvertFrom-Json has no such parameter and + # fails parameter binding, which is the host this collector is deployed under. + $collectorProfile = $rawProfile | ConvertFrom-Json -ErrorAction Stop } catch { throw ('Collector profile contains invalid JSON: {0}. Error: {1}' -f $Path, $_.Exception.Message) @@ -706,6 +744,71 @@ function Test-EventChannelExists { } } +function Export-EventChannelLocaleMetadata { + <# + .SYNOPSIS + Archives an already-exported channel so its message strings travel with the bundle. + + .DESCRIPTION + wevtutil.exe export-log emits only the binary records. Rendering an event description + then requires the originating provider to be registered on whatever machine opens the + file, which is never true for an analyst workstation and is impossible on macOS or Linux. + wevtutil.exe archive-log writes a LocaleMetaData\_.MTA sidecar carrying the + provider's message strings, making the export self-describing. + + The locale is deliberately left to the collecting machine's default so the captured + strings match what the operator saw on that endpoint. The emitted LCID is recorded in + the artifact notes rather than predicted, because the file name depends on it. + #> + param( + [Parameter(Mandatory = $true)] + [string]$EvtxPath, + [Parameter(Mandatory = $true)] + [string]$EvtxRelativePath, + [Parameter(Mandatory = $true)] + [string]$Family, + [Parameter(Mandatory = $true)] + [string]$Channel, + [Parameter(Mandatory = $true)] + [System.Collections.IList]$ObservedGaps + ) + + $records = New-Object 'System.Collections.Generic.List[object]' + $metadataRelativeFolder = Get-LocaleMetadataRelativePath -EvtxRelativePath $EvtxRelativePath + $metadataFolder = Join-Path (Split-Path -Parent $EvtxPath) 'LocaleMetaData' + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($EvtxPath) + + & wevtutil.exe al $EvtxPath | Out-Null + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0) { + $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } + + $metadataFiles = @() + if (Test-Path -LiteralPath $metadataFolder) { + $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction SilentlyContinue) + } + + if ($metadataFiles.Count -eq 0) { + $notes = 'wevtutil.exe al reported success but produced no .MTA sidecar. Event descriptions will not resolve away from this machine.' + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'missing' -Origin $Channel -Reason $notes + return $records + } + + foreach ($metadataFile in $metadataFiles) { + $relativePath = Join-RelativePath -Left $metadataRelativeFolder -Right $metadataFile.Name + $notes = 'Locale metadata for {0}, enabling offline event description rendering.' -f $Channel + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $relativePath -OriginPath $Channel -Status 'collected' -ParseHints @('mta') -FilePath $metadataFile.FullName -Notes $notes)) + } + + return $records +} + function Get-RedactedUploadUrl { param( [AllowEmptyString()] @@ -934,6 +1037,14 @@ try { } $artifacts.Add($artifact) + + if ($artifact.status -ne 'collected' -or $SkipLocaleMetadata) { + continue + } + + foreach ($metadataArtifact in (Export-EventChannelLocaleMetadata -EvtxPath $destinationPath -EvtxRelativePath $relativePath -Family $eventItem.family -Channel $eventItem.channel -ObservedGaps $observedGaps)) { + $artifacts.Add($metadataArtifact) + } } Write-Step 'Collecting exported file artifacts' From 209b595881387159959c0ed1d68fed30335c109a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 14:10:43 -0400 Subject: [PATCH 3/6] fix(collection): address review findings on locale metadata capture Four findings from the PR review, all valid. An enumeration fault was reported as an absence. Get-ChildItem over the LocaleMetaData folder used -ErrorAction SilentlyContinue, so an access or I/O error produced an empty result and a 'missing' artifact, claiming the sidecar was never created when it may simply be unreadable. It now runs with -ErrorAction Stop inside try/catch and records a 'failed' artifact and observed gap. Unresolved outcomes pointed at a directory. The failed and missing records used the LocaleMetaData folder as relativePath. Bundle inspection treats that field as a file and tests it on disk, so those records read as present-on-disk whenever the folder existed, and every channel's failure shared one path. They now use a deterministic file-shaped _unknown-lcid.MTA placeholder, unique per channel. The LCID was documented but never recorded. The function contract said the emitted locale identifier is captured in the artifact notes; nothing parsed it. Get-LocaleMetadataLcid reads it back from the sidecar name, taking the final underscore-delimited segment so exported log names containing underscores still resolve, and returning 'unknown' rather than guessing when the suffix is absent or non-numeric. Test coverage stopped at path derivation. Added cases for LCID extraction including the underscore and non-numeric edges, the file-shaped unresolved path, the enumeration-fault contract, and the -SkipLocaleMetadata opt-out. Verified end to end on Windows 11 x64 under Windows PowerShell 5.1: the default run produced 9 metadata records with notes reading "Locale metadata (LCID 1033)" and correct hashes, and -SkipLocaleMetadata produced 0 records and 0 sidecars. Pester 22/22, reference copy resynced so the parser drift test passes, script parses clean and stays ASCII-only. Co-Authored-By: Claude Opus 5 --- .../Invoke-CmtraceEvidenceCollection.ps1 | 55 ++++++++++++++++-- .../Invoke-CmtraceEvidenceCollection.ps1 | 55 ++++++++++++++++-- ...Invoke-CmtraceEvidenceCollection.Tests.ps1 | 57 ++++++++++++++++++- 3 files changed, 158 insertions(+), 9 deletions(-) diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index 3d979cc77..a6c530003 100644 --- a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -139,6 +139,37 @@ function Get-LocaleMetadataRelativePath { return (Join-RelativePath -Left $normalizedPath.Substring(0, $lastSeparatorIndex) -Right 'LocaleMetaData') } +function Get-LocaleMetadataLcid { + <# + .SYNOPSIS + Extracts the locale identifier wevtutil.exe encoded into an .MTA sidecar's file name. + + .DESCRIPTION + Sidecars are named _.MTA, for example device-management-admin_1033.MTA. + The LCID is read back from the name rather than predicted, because the collecting machine's + default locale decides it and the exported log's own base name may itself contain + underscores. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MetadataFileName + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($MetadataFileName) + $lastSeparatorIndex = $baseName.LastIndexOf('_') + + if ($lastSeparatorIndex -lt 0 -or $lastSeparatorIndex -eq ($baseName.Length - 1)) { + return 'unknown' + } + + $candidate = $baseName.Substring($lastSeparatorIndex + 1) + if ($candidate -notmatch '^\d+$') { + return 'unknown' + } + + return $candidate +} + function ConvertTo-PhysicalPath { param( [Parameter(Mandatory = $true)] @@ -778,31 +809,47 @@ function Export-EventChannelLocaleMetadata { $metadataFolder = Join-Path (Split-Path -Parent $EvtxPath) 'LocaleMetaData' $baseName = [System.IO.Path]::GetFileNameWithoutExtension($EvtxPath) + # Manifest consumers treat relativePath as a file and test it on disk, so an unresolved + # outcome still needs a file-shaped path. Using the LocaleMetaData folder would both look + # present-on-disk whenever the folder exists and collide across channels. + $unresolvedRelativePath = Join-RelativePath -Left $metadataRelativeFolder -Right ('{0}_unknown-lcid.MTA' -f $baseName) + & wevtutil.exe al $EvtxPath | Out-Null $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes return $records } $metadataFiles = @() if (Test-Path -LiteralPath $metadataFolder) { - $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction SilentlyContinue) + try { + $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction Stop) + } + catch { + # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' + # would claim the sidecar was never produced when it may simply be unreadable. + $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } } if ($metadataFiles.Count -eq 0) { $notes = 'wevtutil.exe al reported success but produced no .MTA sidecar. Event descriptions will not resolve away from this machine.' - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'missing' -Origin $Channel -Reason $notes return $records } foreach ($metadataFile in $metadataFiles) { $relativePath = Join-RelativePath -Left $metadataRelativeFolder -Right $metadataFile.Name - $notes = 'Locale metadata for {0}, enabling offline event description rendering.' -f $Channel + $localeId = Get-LocaleMetadataLcid -MetadataFileName $metadataFile.Name + $notes = 'Locale metadata (LCID {0}) for {1}, enabling offline event description rendering.' -f $localeId, $Channel $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $relativePath -OriginPath $Channel -Status 'collected' -ParseHints @('mta') -FilePath $metadataFile.FullName -Notes $notes)) } diff --git a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index 3d979cc77..a6c530003 100644 --- a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -139,6 +139,37 @@ function Get-LocaleMetadataRelativePath { return (Join-RelativePath -Left $normalizedPath.Substring(0, $lastSeparatorIndex) -Right 'LocaleMetaData') } +function Get-LocaleMetadataLcid { + <# + .SYNOPSIS + Extracts the locale identifier wevtutil.exe encoded into an .MTA sidecar's file name. + + .DESCRIPTION + Sidecars are named _.MTA, for example device-management-admin_1033.MTA. + The LCID is read back from the name rather than predicted, because the collecting machine's + default locale decides it and the exported log's own base name may itself contain + underscores. + #> + param( + [Parameter(Mandatory = $true)] + [string]$MetadataFileName + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($MetadataFileName) + $lastSeparatorIndex = $baseName.LastIndexOf('_') + + if ($lastSeparatorIndex -lt 0 -or $lastSeparatorIndex -eq ($baseName.Length - 1)) { + return 'unknown' + } + + $candidate = $baseName.Substring($lastSeparatorIndex + 1) + if ($candidate -notmatch '^\d+$') { + return 'unknown' + } + + return $candidate +} + function ConvertTo-PhysicalPath { param( [Parameter(Mandatory = $true)] @@ -778,31 +809,47 @@ function Export-EventChannelLocaleMetadata { $metadataFolder = Join-Path (Split-Path -Parent $EvtxPath) 'LocaleMetaData' $baseName = [System.IO.Path]::GetFileNameWithoutExtension($EvtxPath) + # Manifest consumers treat relativePath as a file and test it on disk, so an unresolved + # outcome still needs a file-shaped path. Using the LocaleMetaData folder would both look + # present-on-disk whenever the folder exists and collide across channels. + $unresolvedRelativePath = Join-RelativePath -Left $metadataRelativeFolder -Right ('{0}_unknown-lcid.MTA' -f $baseName) + & wevtutil.exe al $EvtxPath | Out-Null $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes return $records } $metadataFiles = @() if (Test-Path -LiteralPath $metadataFolder) { - $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction SilentlyContinue) + try { + $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction Stop) + } + catch { + # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' + # would claim the sidecar was never produced when it may simply be unreadable. + $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } } if ($metadataFiles.Count -eq 0) { $notes = 'wevtutil.exe al reported success but produced no .MTA sidecar. Event descriptions will not resolve away from this machine.' - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $metadataRelativeFolder -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'missing' -ParseHints @('mta') -Notes $notes)) Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'missing' -Origin $Channel -Reason $notes return $records } foreach ($metadataFile in $metadataFiles) { $relativePath = Join-RelativePath -Left $metadataRelativeFolder -Right $metadataFile.Name - $notes = 'Locale metadata for {0}, enabling offline event description rendering.' -f $Channel + $localeId = Get-LocaleMetadataLcid -MetadataFileName $metadataFile.Name + $notes = 'Locale metadata (LCID {0}) for {1}, enabling offline event description rendering.' -f $localeId, $Channel $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $relativePath -OriginPath $Channel -Status 'collected' -ParseHints @('mta') -FilePath $metadataFile.FullName -Notes $notes)) } diff --git a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 index 26690f09e..eca0543c7 100644 --- a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 +++ b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 @@ -21,7 +21,8 @@ BeforeAll { 'Assert-ProfileRequiredArray', 'Assert-CollectorProfileShape', 'Join-RelativePath', - 'Get-LocaleMetadataRelativePath' + 'Get-LocaleMetadataRelativePath', + 'Get-LocaleMetadataLcid' ) foreach ($functionName in $functionNames) { $definition = $ast.FindAll( @@ -166,6 +167,60 @@ Describe 'Get-LocaleMetadataRelativePath' { } } +Describe 'Get-LocaleMetadataLcid' { + It 'reads the LCID wevtutil appends to the sidecar name' { + Get-LocaleMetadataLcid -MetadataFileName 'device-management-admin_1033.MTA' | + Should -BeExactly '1033' + } + + It 'takes the final segment when the exported log name itself contains underscores' { + Get-LocaleMetadataLcid -MetadataFileName 'user_device_registration_2057.MTA' | + Should -BeExactly '2057' + } + + It 'reports unknown rather than guessing when the suffix is not numeric' { + Get-LocaleMetadataLcid -MetadataFileName 'autopilot_enUS.MTA' | Should -BeExactly 'unknown' + } + + It 'reports unknown when there is no suffix at all' { + Get-LocaleMetadataLcid -MetadataFileName 'autopilot.MTA' | Should -BeExactly 'unknown' + Get-LocaleMetadataLcid -MetadataFileName 'autopilot_.MTA' | Should -BeExactly 'unknown' + } +} + +Describe 'Locale metadata artifact contract' { + It 'keeps the unresolved-outcome path file-shaped rather than pointing at the folder' { + # Bundle inspection treats relativePath as a file and tests it on disk. Pointing failure + # and missing records at the LocaleMetaData folder would read as present-on-disk whenever + # the folder exists, and would collide across channels. + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + $collectorText | Should -Match 'unknown-lcid\.MTA' + $collectorText | Should -Not -Match "RelativePath \`$metadataRelativeFolder" + } + + It 'treats a sidecar enumeration fault as failed rather than missing' { + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + # -ErrorAction Stop inside try/catch, so an unreadable folder cannot masquerade as absent. + $collectorText | Should -Match "Get-ChildItem -LiteralPath \`$metadataFolder[^\r\n]*-ErrorAction Stop" + $collectorText | Should -Match 'Could not enumerate' + } + + It 'exposes an opt-out switch for operators who need a smaller bundle' { + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + $collectorText | Should -Match '\[switch\]\$SkipLocaleMetadata' + $collectorText | Should -Match '\$SkipLocaleMetadata\)\s*\{\s*continue' + } + + It 'records the LCID in the collected artifact notes' { + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + $collectorText | Should -Match 'Locale metadata \(LCID \{0\}\)' + } +} + Describe 'Assert-CollectorProfileShape' { It 'accepts unique artifact IDs across all sections' { $profile = New-TestCollectorProfile From bf0b2deb26fe2fb87662f47aebf733c6ec2c2d17 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 15:42:09 -0400 Subject: [PATCH 4/6] fix(collection): probe the sidecar folder inside the error handler Follow-up to the enumeration fix: Get-ChildItem was moved into a try, but the Test-Path guarding it was left outside. This script sets $ErrorActionPreference to 'Stop', so a provider or I/O fault while probing the LocaleMetaData folder would abort the entire collection run rather than recording one failed artifact for that channel, which is the opposite of the coverage discipline the surrounding code follows. Test-Path now runs inside the same try with -ErrorAction Stop, and the existing failed-artifact path handles it. Reference copy resynced. Pester 23/23, script parses clean and stays ASCII-only, collector drift test passes. Co-Authored-By: Claude Opus 5 --- .../Invoke-CmtraceEvidenceCollection.ps1 | 25 +++++++++++-------- .../Invoke-CmtraceEvidenceCollection.ps1 | 25 +++++++++++-------- ...Invoke-CmtraceEvidenceCollection.Tests.ps1 | 8 ++++++ 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index a6c530003..62b634af5 100644 --- a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), [string]$BundleLabel = 'intune-endpoint-evidence', @@ -824,19 +824,22 @@ function Export-EventChannelLocaleMetadata { return $records } + # Test-Path is inside the try deliberately. $ErrorActionPreference is 'Stop' for this script, so + # a provider or I/O fault while probing the folder would otherwise abort the entire collection + # rather than recording a failed artifact for this one channel. $metadataFiles = @() - if (Test-Path -LiteralPath $metadataFolder) { - try { + try { + if (Test-Path -LiteralPath $metadataFolder -ErrorAction Stop) { $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction Stop) } - catch { - # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' - # would claim the sidecar was never produced when it may simply be unreadable. - $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) - Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes - return $records - } + } + catch { + # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' would + # claim the sidecar was never produced when it may simply be unreadable. + $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records } if ($metadataFiles.Count -eq 0) { diff --git a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index a6c530003..62b634af5 100644 --- a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), [string]$BundleLabel = 'intune-endpoint-evidence', @@ -824,19 +824,22 @@ function Export-EventChannelLocaleMetadata { return $records } + # Test-Path is inside the try deliberately. $ErrorActionPreference is 'Stop' for this script, so + # a provider or I/O fault while probing the folder would otherwise abort the entire collection + # rather than recording a failed artifact for this one channel. $metadataFiles = @() - if (Test-Path -LiteralPath $metadataFolder) { - try { + try { + if (Test-Path -LiteralPath $metadataFolder -ErrorAction Stop) { $metadataFiles = @(Get-ChildItem -LiteralPath $metadataFolder -Filter ('{0}_*.MTA' -f $baseName) -File -ErrorAction Stop) } - catch { - # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' - # would claim the sidecar was never produced when it may simply be unreadable. - $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) - $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) - Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes - return $records - } + } + catch { + # An access or I/O fault here is a failure, not an absence. Reporting it as 'missing' would + # claim the sidecar was never produced when it may simply be unreadable. + $notes = 'Could not enumerate {0}: {1}' -f $metadataFolder, (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records } if ($metadataFiles.Count -eq 0) { diff --git a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 index eca0543c7..1144a0be0 100644 --- a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 +++ b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 @@ -199,6 +199,14 @@ Describe 'Locale metadata artifact contract' { $collectorText | Should -Not -Match "RelativePath \`$metadataRelativeFolder" } + It 'probes the sidecar folder inside the try, so a fault cannot abort collection' { + # $ErrorActionPreference is 'Stop', so a Test-Path outside the try would take down the whole + # run instead of recording one failed artifact. + $collectorText = Get-Content -LiteralPath $collectorPath -Raw + + $collectorText | Should -Match "try \{\s*\r?\n\s*if \(Test-Path -LiteralPath \`$metadataFolder -ErrorAction Stop\)" + } + It 'treats a sidecar enumeration fault as failed rather than missing' { $collectorText = Get-Content -LiteralPath $collectorPath -Raw From b0af592269e3e2487390701109cba8557cce83e4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 19:43:17 -0400 Subject: [PATCH 5/6] fix(collection): drop the UTF-8 BOM from both collector copies The files opened with EF BB BF before [CmdletBinding()]. Nothing in either is non-ASCII, so the BOM bought nothing and cost the one thing it can: any path that reads the script as bytes rather than through PowerShell's own encoding detection sees three junk characters ahead of the first token. Piping the file into a remote shell is exactly such a path, and it is how this collector gets deployed. Verified on the real thing rather than reasoned about: PowerShell 5.1.26100 parses the stripped file to 7,303 tokens with no errors and builds a scriptblock from all 55,124 characters. Both copies stay byte-identical, which the parser crate asserts. Also checked the rest of the class: neither copy contains any other non-ASCII byte, so this was the only instance. Co-Authored-By: Claude Opus 5 --- references/collection/Invoke-CmtraceEvidenceCollection.ps1 | 2 +- scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index 62b634af5..de2192b21 100644 --- a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), [string]$BundleLabel = 'intune-endpoint-evidence', diff --git a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index 62b634af5..de2192b21 100644 --- a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$OutputRoot = (Join-Path $env:ProgramData 'CmtraceOpen\Evidence'), [string]$BundleLabel = 'intune-endpoint-evidence', From 096c05778404ae0010065c78f29b724b6c0aaeaf Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 20:44:09 -0400 Subject: [PATCH 6/6] fix(collection): guard the wevtutil invocation and test behaviour, not source text Three review findings, all real. The wevtutil.exe call was unguarded. $ErrorActionPreference is 'Stop', so the command being absent from PATH or failing to launch took down the entire collection rather than recording one failed channel. That is the same class as the Test-Path case fixed earlier in this branch; that fix closed one instance and left the other open. A missing wevtutil.exe is not far-fetched on a locked down host, and losing every other artifact in the bundle over one sidecar is the worst available outcome. $profile in the test suite shadows a PowerShell automatic variable. Renamed to $testProfile. Checked the rest of the class across both scripts and the tests: no other automatic variable is shadowed anywhere. The locale metadata tests asserted the collector's source text matched five regexes. That passes just as happily when the behaviour is wrong and breaks on a harmless reformat. They now load Export-EventChannelLocaleMetadata through the suite's existing AST loader and invoke it against a stubbed wevtutil.exe and a temporary sidecar folder, asserting the returned records and the observed gaps. Function definitions win over external commands in PowerShell's resolution order, so the stub is reached without changing the collector, and the tests run off Windows. Verified the tests can actually fail: removing the wevtutil guard fails one, and reporting an enumeration fault as 'missing' rather than 'failed' fails another. 26 pass on Pester 6, and PowerShell 5.1.26100 parses the collector to 7,375 tokens with no errors. Only the SkipLocaleMetadata assertion stays text-based, because the switch is a parameter on the script itself and there is no unit to invoke. Both copies stay byte-identical and ASCII with no BOM. Co-Authored-By: Claude Opus 5 --- .../Invoke-CmtraceEvidenceCollection.ps1 | 17 +- .../Invoke-CmtraceEvidenceCollection.ps1 | 17 +- ...Invoke-CmtraceEvidenceCollection.Tests.ps1 | 197 ++++++++++++++---- 3 files changed, 189 insertions(+), 42 deletions(-) diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index de2192b21..471a60f30 100644 --- a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -814,8 +814,21 @@ function Export-EventChannelLocaleMetadata { # present-on-disk whenever the folder exists and collide across channels. $unresolvedRelativePath = Join-RelativePath -Left $metadataRelativeFolder -Right ('{0}_unknown-lcid.MTA' -f $baseName) - & wevtutil.exe al $EvtxPath | Out-Null - $exitCode = $LASTEXITCODE + # Invoking wevtutil.exe is inside the try for the same reason Test-Path below is: + # $ErrorActionPreference is 'Stop' for this script, so the command being absent from PATH, or + # failing to launch at all, would abort the entire collection instead of recording one failed + # channel. A missing wevtutil.exe is not far-fetched on a locked-down or constrained host, and + # losing every other artifact over it is the worst possible outcome. + try { + & wevtutil.exe al $EvtxPath | Out-Null + $exitCode = $LASTEXITCODE + } + catch { + $notes = 'Could not run wevtutil.exe al: {0}. Event descriptions will not resolve away from this machine.' -f (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } if ($exitCode -ne 0) { $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode diff --git a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index de2192b21..471a60f30 100644 --- a/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 +++ b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 @@ -814,8 +814,21 @@ function Export-EventChannelLocaleMetadata { # present-on-disk whenever the folder exists and collide across channels. $unresolvedRelativePath = Join-RelativePath -Left $metadataRelativeFolder -Right ('{0}_unknown-lcid.MTA' -f $baseName) - & wevtutil.exe al $EvtxPath | Out-Null - $exitCode = $LASTEXITCODE + # Invoking wevtutil.exe is inside the try for the same reason Test-Path below is: + # $ErrorActionPreference is 'Stop' for this script, so the command being absent from PATH, or + # failing to launch at all, would abort the entire collection instead of recording one failed + # channel. A missing wevtutil.exe is not far-fetched on a locked-down or constrained host, and + # losing every other artifact over it is the worst possible outcome. + try { + & wevtutil.exe al $EvtxPath | Out-Null + $exitCode = $LASTEXITCODE + } + catch { + $notes = 'Could not run wevtutil.exe al: {0}. Event descriptions will not resolve away from this machine.' -f (Protect-SecretText -Text $_.Exception.Message) + $records.Add((New-ArtifactRecord -Category 'event-log-metadata' -Family $Family -RelativePath $unresolvedRelativePath -OriginPath $Channel -Status 'failed' -ParseHints @('mta') -Notes $notes)) + Add-ObservedGap -ObservedGaps $ObservedGaps -Status 'failed' -Origin $Channel -Reason $notes + return $records + } if ($exitCode -ne 0) { $notes = 'wevtutil.exe al failed with exit code {0}. Event descriptions will not resolve away from this machine.' -f $exitCode diff --git a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 index 1144a0be0..04ccf977e 100644 --- a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 +++ b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 @@ -22,7 +22,14 @@ BeforeAll { 'Assert-CollectorProfileShape', 'Join-RelativePath', 'Get-LocaleMetadataRelativePath', - 'Get-LocaleMetadataLcid' + 'Get-LocaleMetadataLcid', + 'Protect-SecretText', + 'Get-FileSha256', + 'New-ArtifactId', + 'Get-UtcTimestamp', + 'New-ArtifactRecord', + 'Add-ObservedGap', + 'Export-EventChannelLocaleMetadata' ) foreach ($functionName in $functionNames) { $definition = $ast.FindAll( @@ -37,6 +44,10 @@ BeforeAll { Invoke-Expression $definition.Extent.Text } + # The collector initializes this at its own top level, which AST-loading individual functions + # skips. New-ArtifactId increments through it, so without it every record build faults. + $script:ArtifactCounters = @{} + function New-TestCollectorProfile { param( [string]$LogId = 'logs-primary', @@ -106,26 +117,26 @@ Describe 'Intune evidence profile contracts' { Describe 'Optional array validation' { It 'accepts a single-element optional array such as arguments: ["/status"]' { - $profile = New-TestCollectorProfile - $profile.commands[0].arguments = @('/status') + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = @('/status') - { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | Should -Not -Throw } It 'still accepts an empty optional array' { - $profile = New-TestCollectorProfile - $profile.commands[0].arguments = @() + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = @() - { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | Should -Not -Throw } It 'still rejects a scalar where an array is required' { - $profile = New-TestCollectorProfile - $profile.commands[0].arguments = '/status' + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = '/status' - { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | Should -Throw -ExpectedMessage '*commands[[]0[]].arguments must be an array when present*' } } @@ -188,59 +199,169 @@ Describe 'Get-LocaleMetadataLcid' { } } -Describe 'Locale metadata artifact contract' { - It 'keeps the unresolved-outcome path file-shaped rather than pointing at the folder' { - # Bundle inspection treats relativePath as a file and tests it on disk. Pointing failure - # and missing records at the LocaleMetaData folder would read as present-on-disk whenever - # the folder exists, and would collide across channels. - $collectorText = Get-Content -LiteralPath $collectorPath -Raw +Describe 'Export-EventChannelLocaleMetadata' { + # These invoke the function rather than pattern-matching the collector's source. Asserting that + # the code reads a certain way passes just as happily when the behaviour is wrong, and breaks on + # a harmless reformat. wevtutil.exe is stubbed, which also lets these run off Windows. + + BeforeAll { + function Set-WevtutilStub { + param( + [int]$ExitCode = 0, + [string[]]$SidecarNames = @(), + [switch]$Throw + ) + $folder = Join-Path $script:channelFolder 'LocaleMetaData' + $shouldThrow = [bool]$Throw + $names = $SidecarNames + $code = $ExitCode + # Function definitions win over external commands in PowerShell's resolution order, so the + # collector's own `& wevtutil.exe` call reaches this. + Set-Item -Path 'function:global:wevtutil.exe' -Value { + if ($shouldThrow) { throw 'wevtutil.exe is not recognized' } + if ($names.Count -gt 0) { + New-Item -ItemType Directory -Path $folder -Force | Out-Null + foreach ($name in $names) { + Set-Content -LiteralPath (Join-Path $folder $name) -Value 'sidecar' -Encoding ascii + } + } + $global:LASTEXITCODE = $code + }.GetNewClosure() + } - $collectorText | Should -Match 'unknown-lcid\.MTA' - $collectorText | Should -Not -Match "RelativePath \`$metadataRelativeFolder" + function Invoke-Subject { + Export-EventChannelLocaleMetadata ` + -EvtxPath $script:evtxPath ` + -EvtxRelativePath 'eventlogs/Application.evtx' ` + -Family 'eventlogs' ` + -Channel 'Application' ` + -ObservedGaps $script:gaps + } } - It 'probes the sidecar folder inside the try, so a fault cannot abort collection' { - # $ErrorActionPreference is 'Stop', so a Test-Path outside the try would take down the whole - # run instead of recording one failed artifact. - $collectorText = Get-Content -LiteralPath $collectorPath -Raw + BeforeEach { + $script:sandbox = Join-Path ([IO.Path]::GetTempPath()) ('cmt-locale-' + [Guid]::NewGuid().ToString('N')) + $script:channelFolder = Join-Path $script:sandbox 'eventlogs' + New-Item -ItemType Directory -Path $script:channelFolder -Force | Out-Null + $script:evtxPath = Join-Path $script:channelFolder 'Application.evtx' + Set-Content -LiteralPath $script:evtxPath -Value 'not a real evtx' -Encoding ascii + $script:gaps = New-Object 'System.Collections.Generic.List[string]' + } - $collectorText | Should -Match "try \{\s*\r?\n\s*if \(Test-Path -LiteralPath \`$metadataFolder -ErrorAction Stop\)" + AfterEach { + if (Test-Path -LiteralPath $script:sandbox) { + Remove-Item -LiteralPath $script:sandbox -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Item -Path 'function:wevtutil.exe' -ErrorAction SilentlyContinue } - It 'treats a sidecar enumeration fault as failed rather than missing' { - $collectorText = Get-Content -LiteralPath $collectorPath -Raw + It 'records the produced sidecar with its LCID and a hash' { + Set-WevtutilStub -SidecarNames @('Application_1033.MTA') - # -ErrorAction Stop inside try/catch, so an unreadable folder cannot masquerade as absent. - $collectorText | Should -Match "Get-ChildItem -LiteralPath \`$metadataFolder[^\r\n]*-ErrorAction Stop" - $collectorText | Should -Match 'Could not enumerate' + $records = @(Invoke-Subject) + + $records.Count | Should -Be 1 + $records[0].status | Should -BeExactly 'collected' + $records[0].relativePath | Should -BeExactly 'eventlogs/LocaleMetaData/Application_1033.MTA' + $records[0].notes | Should -BeLike '*LCID 1033*' + $records[0].hashes.sha256 | Should -Not -BeNullOrEmpty + $script:gaps.Count | Should -Be 0 } - It 'exposes an opt-out switch for operators who need a smaller bundle' { - $collectorText = Get-Content -LiteralPath $collectorPath -Raw + It 'records every sidecar when the machine emitted more than one locale' { + Set-WevtutilStub -SidecarNames @('Application_1033.MTA', 'Application_2057.MTA') - $collectorText | Should -Match '\[switch\]\$SkipLocaleMetadata' - $collectorText | Should -Match '\$SkipLocaleMetadata\)\s*\{\s*continue' + $records = @(Invoke-Subject) + + $records.Count | Should -Be 2 + @($records.relativePath) | Should -Contain 'eventlogs/LocaleMetaData/Application_2057.MTA' + } + + It 'reports a nonzero exit code as failed, not as a missing file' { + Set-WevtutilStub -ExitCode 5 + + $records = @(Invoke-Subject) + + $records.Count | Should -Be 1 + $records[0].status | Should -BeExactly 'failed' + $records[0].notes | Should -BeLike '*exit code 5*' + $script:gaps.Count | Should -Be 1 + $script:gaps[0] | Should -BeLike 'Collection failed for Application*' } - It 'records the LCID in the collected artifact notes' { + It 'survives wevtutil.exe being absent instead of aborting the collection' { + # $ErrorActionPreference is 'Stop', so an unguarded invocation would take the whole run down + # over one channel. The other artifacts in the bundle matter more than this sidecar. + Set-WevtutilStub -Throw + + { Invoke-Subject } | Should -Not -Throw + + $records = @(Invoke-Subject) + $records[0].status | Should -BeExactly 'failed' + $records[0].notes | Should -BeLike '*Could not run wevtutil.exe*' + } + + It 'reports success with no sidecar as missing rather than collected' { + Set-WevtutilStub -SidecarNames @() + + $records = @(Invoke-Subject) + + $records.Count | Should -Be 1 + $records[0].status | Should -BeExactly 'missing' + $script:gaps[0] | Should -BeLike 'Missing expected artifact*' + } + + It 'keeps an unresolved outcome file-shaped rather than pointing at the folder' { + # Bundle inspection treats relativePath as a file and tests it on disk. Pointing a failure + # at the LocaleMetaData folder would read as present-on-disk whenever the folder exists, + # and would collide across channels. + Set-WevtutilStub -ExitCode 5 + + $records = @(Invoke-Subject) + + $records[0].relativePath | Should -BeExactly 'eventlogs/LocaleMetaData/Application_unknown-lcid.MTA' + } + + It 'treats an unreadable sidecar folder as failed rather than missing' { + # An access fault is not an absence. Calling it 'missing' would claim the sidecar was never + # produced when it may simply be unreadable. + Set-WevtutilStub -SidecarNames @('Application_1033.MTA') + $folder = Join-Path $script:channelFolder 'LocaleMetaData' + New-Item -ItemType Directory -Path $folder -Force | Out-Null + # A file where the folder is expected makes enumeration fault rather than return empty. + Mock -CommandName Get-ChildItem -MockWith { throw 'Access to the path is denied.' } + + $records = @(Invoke-Subject) + + $records[0].status | Should -BeExactly 'failed' + $records[0].notes | Should -BeLike 'Could not enumerate*' + $script:gaps[0] | Should -BeLike 'Collection failed for Application*' + } +} + +Describe 'Locale metadata opt-out' { + It 'exposes a switch for operators who need a smaller bundle' { + # The switch is a parameter on the script itself rather than on a function, so the + # declaration is the thing to assert; there is no unit to invoke. $collectorText = Get-Content -LiteralPath $collectorPath -Raw - $collectorText | Should -Match 'Locale metadata \(LCID \{0\}\)' + $collectorText | Should -Match '\[switch\]\$SkipLocaleMetadata' + $collectorText | Should -Match '\$SkipLocaleMetadata\)\s*\{\s*continue' } } Describe 'Assert-CollectorProfileShape' { It 'accepts unique artifact IDs across all sections' { - $profile = New-TestCollectorProfile + $testProfile = New-TestCollectorProfile - { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | Should -Not -Throw } It 'rejects case-insensitive duplicate artifact IDs across sections' { - $profile = New-TestCollectorProfile -LogId 'shared-artifact' -CommandId 'SHARED-ARTIFACT' + $testProfile = New-TestCollectorProfile -LogId 'shared-artifact' -CommandId 'SHARED-ARTIFACT' - { Assert-CollectorProfileShape -CollectorProfile $profile -Path 'profile.json' } | + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | Should -Throw -ExpectedMessage '*duplicate artifact id*shared-artifact*first declared at logs*repeated at commands*' } }