Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions modules/events/diagnostic.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '(?<Code>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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 28 additions & 4 deletions tests/diagnostic-classification.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,48 @@ $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.'
Assert-True ($null -eq (Get-EventSignalRule 'System' 'Microsoft-Windows-Kernel-Power' 41 2)) 'Kernel-Power 41 with the wrong level must not match.'
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 }
)
$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 = @(
Expand Down Expand Up @@ -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 }
)
}
Expand All @@ -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.'
Expand Down
Loading