diff --git a/modules/events/diagnostic.ps1 b/modules/events/diagnostic.ps1 index 2179266..c06e030 100644 --- a/modules/events/diagnostic.ps1 +++ b/modules/events/diagnostic.ps1 @@ -38,6 +38,81 @@ function ConvertTo-OneLineMessage { return ($singleLine.Substring(0, $MaxLength - 3) + '...') } + +function Get-EventErrorCodes { + param([AllowEmptyString()][string]$Message) + + if ([string]::IsNullOrWhiteSpace($Message)) { + return @() + } + + # Event messages often contain many hexadecimal values that are not error + # codes (timestamps, fault offsets, handles, identifiers). Only accept + # values explicitly labelled as an error/status/exception code. + $labelPattern = '(?:HRESULT|NTSTATUS|(?:error|exception|failure|status|return)\s+code|status|\u043A\u043E\u0434\s+(?:\u043E\u0448\u0438\u0431\u043A\u0438|\u0438\u0441\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u044F|\u0441\u043E\u0441\u0442\u043E\u044F\u043D\u0438\u044F|\u0441\u0431\u043E\u044F|\u0432\u043E\u0437\u0432\u0440\u0430\u0442\u0430))' + $valuePattern = '(?0x[0-9A-F]{1,16}|-?\d+)' + $regex = New-Object System.Text.RegularExpressions.Regex( + ('(?i){0}\s*[:=]?\s*{1}' -f $labelPattern, $valuePattern) + ) + + $seen = @{} + $codes = New-Object System.Collections.Generic.List[string] + foreach ($match in $regex.Matches($Message)) { + $value = [string]$match.Groups['Code'].Value + if ($value -match '^(?i)0x') { + $normalized = '0x' + $value.Substring(2).ToUpperInvariant() + } + else { + $normalized = $value + } + + if ($seen.ContainsKey($normalized)) { + continue + } + + $seen[$normalized] = $true + $codes.Add($normalized) + } + + return @($codes.ToArray()) +} + +function Get-EventDesignation { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ProviderName, + [Parameter(Mandatory = $true)][int]$Id, + [AllowEmptyString()][string]$Message, + $SignalRule + ) + + if ($null -ne $SignalRule -and -not [string]::IsNullOrWhiteSpace([string]$SignalRule.Message)) { + return ([string]$SignalRule.Message).Trim().TrimEnd('.') + } + + if (-not [string]::IsNullOrWhiteSpace($Message)) { + # Preserve the first meaningful line before collapsing the full event + # text. Many Windows events are multiline key/value records; joining + # them first makes the designation unreadable. + $firstLine = @( + [System.Text.RegularExpressions.Regex]::Split($Message, '\r?\n') | + ForEach-Object { $_.Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -First 1 + ) + + $designationSource = if ($firstLine.Count -gt 0) { [string]$firstLine[0] } else { $Message } + $normalized = ConvertTo-OneLineMessage -Message $designationSource -MaxLength 160 + if ($normalized -match '^.+?[.!?](?=\s|$)') { + return $Matches[0].Trim() + } + + return $normalized + } + + $providerLabel = if ([string]::IsNullOrWhiteSpace($ProviderName)) { 'Unknown provider' } else { $ProviderName } + return ('{0} event {1}' -f $providerLabel, $Id) +} + function Get-EventSignalRule { param( [Parameter(Mandatory = $true)][AllowEmptyString()][string]$LogName, @@ -132,9 +207,21 @@ function Group-EventLogEvents { } } + $eventIdentifier = if ([string]::IsNullOrWhiteSpace($providerName)) { + 'UnknownProvider/{0}' -f $eventId + } + else { + '{0}/{1}' -f $providerName, $eventId + } + $designation = Get-EventDesignation -ProviderName $providerName -Id $eventId -Message ([string]$representativeEvent.Message) -SignalRule $signalRule + $errorCodes = @(Get-EventErrorCodes -Message ([string]$representativeEvent.Message)) + [pscustomobject][ordered]@{ ProviderName = $providerName Id = $eventId + EventIdentifier = $eventIdentifier + Designation = $designation + ErrorCodes = @($errorCodes) Level = $level LevelDisplayName = $levelDisplayName Count = $orderedEvents.Count @@ -373,8 +460,13 @@ if ($displayedGroups.Count -eq 0) { } else { foreach ($group in $displayedGroups) { + $errorCodeText = if (@($group.ErrorCodes).Count -eq 0) { 'None detected in event text' } else { @($group.ErrorCodes) -join ', ' } + Write-Host ('ProviderName : {0}' -f $group.ProviderName) Write-Host ('Id : {0}' -f $group.Id) + Write-Host ('Event identifier : {0}' -f $group.EventIdentifier) + Write-Host ('Designation : {0}' -f $group.Designation) + Write-Host ('Error code(s) : {0}' -f $errorCodeText) Write-Host ('Level : {0}' -f $group.LevelDisplayName) Write-Host ('LogNames : {0}' -f ($group.LogNames -join ', ')) Write-Host ('Count : {0}' -f $group.Count) diff --git a/tests/diagnostic-classification.tests.ps1 b/tests/diagnostic-classification.tests.ps1 index c7b9026..326ff57 100644 --- a/tests/diagnostic-classification.tests.ps1 +++ b/tests/diagnostic-classification.tests.ps1 @@ -21,7 +21,7 @@ $now = Get-Date # Event Log fixtures: severity alone is context; exact rules, grouping, cutoff, # provider identity, and partial source availability are deterministic. $eventsScript = Join-Path $repositoryRoot 'modules\events\diagnostic.ps1' -Import-TestFunctions $eventsScript @('ConvertTo-OneLineMessage','Get-EventSignalRule','Group-EventLogEvents','Read-EventLog') +Import-TestFunctions $eventsScript @('ConvertTo-OneLineMessage','Get-EventErrorCodes','Get-EventDesignation','Get-EventSignalRule','Group-EventLogEvents','Read-EventLog') Assert-True ($null -eq (Get-EventSignalRule 'Application' 'Fixture-Provider' 1000 2)) 'A generic Error event must remain context.' Assert-True ($null -eq (Get-EventSignalRule 'System' 'Microsoft-Windows-DistributedCOM' 10016 2)) 'Expected DCOM 10016 noise must remain context.' Assert-True ($null -eq (Get-EventSignalRule 'System' 'Fixture-Kernel-Power' 41 1)) 'Event ID 41 from another provider must not match.' @@ -29,11 +29,28 @@ Assert-True ($null -eq (Get-EventSignalRule 'System' 'Microsoft-Windows-Kernel-P Assert-Equal 'EVENT_UNEXPECTED_SHUTDOWN' (Get-EventSignalRule 'System' 'Microsoft-Windows-Kernel-Power' 41 1).Code 'Kernel-Power 41 must be a documented signal.' Assert-Equal 'EVENT_FILE_SYSTEM_CORRUPTION' (Get-EventSignalRule 'System' 'Ntfs' 55 2).Code 'NTFS 55 must be a documented signal.' +$errorCodeFixture = 'Failure HRESULT 0x80070005; status 0xc0000005; duplicate HRESULT 0X80070005.' +$errorCodeValues = @(Get-EventErrorCodes $errorCodeFixture) +Assert-Equal 2 $errorCodeValues.Count 'Event error-code extraction must deduplicate codes case-insensitively.' +Assert-Equal '0x80070005' $errorCodeValues[0] 'HRESULT normalization failed.' +Assert-Equal '0xC0000005' $errorCodeValues[1] 'NTSTATUS-style code normalization failed.' +Assert-Equal 0 @(Get-EventErrorCodes 'No hexadecimal error code here.').Count 'Events without hexadecimal codes must not invent one.' +$applicationErrorCodes = @(Get-EventErrorCodes 'Faulting app timestamp: 0x5c157f86; faulting module timestamp: 0x5c157efa; Exception code: 0xc0000005; Fault offset: 0x00001581; process id: 0x9adc.') +Assert-Equal 1 $applicationErrorCodes.Count 'Unlabelled hexadecimal metadata must not be reported as error codes.' +Assert-Equal '0xC0000005' $applicationErrorCodes[0] 'The labelled exception code must be retained.' +$russianErrorText = '"\u041A\u043E\u0434 \u043E\u0448\u0438\u0431\u043A\u0438: -2147024891"' | ConvertFrom-Json +$decimalErrorCodes = @(Get-EventErrorCodes $russianErrorText) +Assert-Equal 1 $decimalErrorCodes.Count 'A labelled decimal Windows error code must produce exactly one code.' +Assert-Equal '-2147024891' $decimalErrorCodes[0] 'Labelled decimal Windows error codes must be retained.' +Assert-Equal 'Service failed to start.' (Get-EventDesignation 'Fixture-Service' 7000 'Service failed to start. Extra diagnostic text follows.' $null) 'Generic event designation must use the first concise sentence from the event message.' +Assert-Equal 'Faulting application: app.exe' (Get-EventDesignation 'Application Error' 1000 "Faulting application: app.exe`r`nFaulting module: module.dll`r`nException code: 0xc0000005" $null) 'Multiline event designations must use only the first meaningful line.' +Assert-Equal 'Windows recorded an unexpected shutdown or restart' (Get-EventDesignation 'Microsoft-Windows-Kernel-Power' 41 'fixture' (Get-EventSignalRule 'System' 'Microsoft-Windows-Kernel-Power' 41 1)) 'Documented signal designation must take precedence over raw event text.' + $eventCutoff = $now.AddHours(-24) $eventFixtures = @( [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=7000; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-3); Message='first'; RecordId=1 }, [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=7000; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-2); Message='second'; RecordId=2 }, - [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=7000; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-1); Message='representative'; RecordId=3 }, + [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=7000; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddHours(-1); Message='Representative failure. Error code: 0x80070005'; RecordId=3 }, [pscustomobject]@{ ProviderName='Other-Provider'; Id=7000; Level=2; LevelDisplayName='Error'; LogName='Application'; TimeCreated=$now.AddMinutes(-30); Message='other provider'; RecordId=4 }, [pscustomobject]@{ ProviderName='Microsoft-Windows-Kernel-Power'; Id=41; Level=1; LevelDisplayName='Critical'; LogName='System'; TimeCreated=$now.AddDays(-2); Message='old signal'; RecordId=5 } ) @@ -41,7 +58,11 @@ $eventGroups = @(Group-EventLogEvents $eventFixtures $eventCutoff) Assert-Equal 2 $eventGroups.Count 'Different providers with the same Event ID must remain separate, and old events must be excluded.' $repeatedEventGroup = @($eventGroups | Where-Object { $_.ProviderName -eq 'Fixture-Provider' })[0] Assert-Equal 3 $repeatedEventGroup.Count 'Repeated events must be grouped.' -Assert-Equal 'representative' $repeatedEventGroup.RepresentativeMessage 'The latest event must provide the representative message.' +Assert-Equal 'Representative failure. Error code: 0x80070005' $repeatedEventGroup.RepresentativeMessage 'The latest event must provide the representative message.' +Assert-Equal 'Fixture-Provider/7000' $repeatedEventGroup.EventIdentifier 'Grouped events must expose a stable provider/Event ID identifier.' +Assert-Equal 'Representative failure.' $repeatedEventGroup.Designation 'Generic grouped events must expose a concise human-readable designation.' +Assert-Equal 1 @($repeatedEventGroup.ErrorCodes).Count 'Grouped events must expose error codes from the representative event text.' +Assert-Equal '0x80070005' $repeatedEventGroup.ErrorCodes[0] 'Grouped event error code was not normalized.' Assert-True (-not $repeatedEventGroup.IsSignal) 'A grouped generic Error event must not become a finding.' $messageFixtures = @( @@ -83,7 +104,7 @@ try { return [pscustomobject]@{ ProviderName='Microsoft-Windows-Kernel-Power'; Id=41; Level=1; LevelDisplayName='Critical'; LogName='System'; TimeCreated=$recent; Message='fixture unexpected restart'; RecordId=42 } } return @( - [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=1000; Level=2; LevelDisplayName='Error'; LogName='System'; TimeCreated=$recent; Message='generic error'; RecordId=41 }, + [pscustomobject]@{ ProviderName='Fixture-Provider'; Id=1000; Level=2; LevelDisplayName='Error'; LogName='System'; TimeCreated=$recent; Message='Generic failure. HRESULT 0x80070005.'; RecordId=41 }, [pscustomobject]@{ ProviderName='Microsoft-Windows-Kernel-Power'; Id=41; Level=1; LevelDisplayName='Critical'; LogName='System'; TimeCreated=$recent; Message='fixture unexpected restart'; RecordId=42 } ) } @@ -109,6 +130,9 @@ finally { } $eventModuleText = $eventModuleOutput -join "`n" Assert-True ($eventModuleText.Contains('EVENT_UNEXPECTED_SHUTDOWN')) 'A documented high-signal event must emit a finding.' +Assert-True ($eventModuleText.Contains('Event identifier : Fixture-Provider/1000')) 'Event output must include provider/Event ID identifier.' +Assert-True ($eventModuleText.Contains('Designation : Generic failure.')) 'Event output must include a concise designation.' +Assert-True ($eventModuleText.Contains('Error code(s) : 0x80070005')) 'Event output must include hexadecimal Windows error codes found in the event message.' Assert-True (-not $eventModuleText.Contains('RECENT_ERROR_EVENTS')) 'A generic Error event must not emit the legacy blanket finding.' Assert-True (-not $eventModuleText.Contains('EVENT_LOG_SOURCE_UNAVAILABLE')) 'Partial event-log access must remain context.' Assert-True (-not $eventModuleText.Contains('EVENT_LOG_ASSESSMENT_UNAVAILABLE')) 'One unavailable Event Log source with working fallbacks must remain context.'