diff --git a/references/collection/Invoke-CmtraceEvidenceCollection.ps1 b/references/collection/Invoke-CmtraceEvidenceCollection.ps1 index aa4311f21..471a60f30 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,61 @@ 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 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)] @@ -223,6 +279,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 +415,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 +453,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 +775,103 @@ 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) + + # 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) + + # 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 + $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 + } + + # 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 = @() + 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 + } + + 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 $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 + $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)) + } + + return $records +} + function Get-RedactedUploadUrl { param( [AllowEmptyString()] @@ -934,6 +1100,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/Invoke-CmtraceEvidenceCollection.ps1 b/scripts/collection/Invoke-CmtraceEvidenceCollection.ps1 index aa4311f21..471a60f30 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,61 @@ 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 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)] @@ -223,6 +279,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 +415,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 +453,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 +775,103 @@ 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) + + # 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) + + # 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 + $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 + } + + # 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 = @() + 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 + } + + 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 $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 + $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)) + } + + return $records +} + function Get-RedactedUploadUrl { param( [AllowEmptyString()] @@ -934,6 +1100,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..04ccf977e 100644 --- a/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 +++ b/scripts/collection/tests/Invoke-CmtraceEvidenceCollection.Tests.ps1 @@ -19,7 +19,17 @@ BeforeAll { 'Test-ArrayValue', 'Assert-ProfileRequiredString', 'Assert-ProfileRequiredArray', - 'Assert-CollectorProfileShape' + 'Assert-CollectorProfileShape', + 'Join-RelativePath', + 'Get-LocaleMetadataRelativePath', + 'Get-LocaleMetadataLcid', + 'Protect-SecretText', + 'Get-FileSha256', + 'New-ArtifactId', + 'Get-UtcTimestamp', + 'New-ArtifactRecord', + 'Add-ObservedGap', + 'Export-EventChannelLocaleMetadata' ) foreach ($functionName in $functionNames) { $definition = $ast.FindAll( @@ -34,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', @@ -101,18 +115,253 @@ Describe 'Intune evidence profile contracts' { } } +Describe 'Optional array validation' { + It 'accepts a single-element optional array such as arguments: ["/status"]' { + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = @('/status') + + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | + Should -Not -Throw + } + + It 'still accepts an empty optional array' { + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = @() + + { Assert-CollectorProfileShape -CollectorProfile $testProfile -Path 'profile.json' } | + Should -Not -Throw + } + + It 'still rejects a scalar where an array is required' { + $testProfile = New-TestCollectorProfile + $testProfile.commands[0].arguments = '/status' + + { Assert-CollectorProfileShape -CollectorProfile $testProfile -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 '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 '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() + } + + function Invoke-Subject { + Export-EventChannelLocaleMetadata ` + -EvtxPath $script:evtxPath ` + -EvtxRelativePath 'eventlogs/Application.evtx' ` + -Family 'eventlogs' ` + -Channel 'Application' ` + -ObservedGaps $script:gaps + } + } + + 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]' + } + + 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 'records the produced sidecar with its LCID and a hash' { + Set-WevtutilStub -SidecarNames @('Application_1033.MTA') + + $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 'records every sidecar when the machine emitted more than one locale' { + Set-WevtutilStub -SidecarNames @('Application_1033.MTA', 'Application_2057.MTA') + + $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 '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 '\[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*' } }