Public/eventlog/Get-SchannelError.ps1
|
#Requires -Version 5.1 function Get-SchannelError { <# .SYNOPSIS Report Schannel TLS, certificate, and negotiation failures from the System log .DESCRIPTION Queries the Schannel provider in the Windows System event log for common TLS and certificate failures. Event XML is parsed for stable diagnostic fields so localized message text does not drive classification or extraction. Results classify each failure as Certificate, Protocol, Cipher, Alert, or Unknown, aggregate equivalent occurrences, and return newest events first. Missing XML fields remain empty, sensitive key and secret fields are never extracted, and a failure on one computer does not stop the remaining computers from being processed. .PARAMETER ComputerName One or more computer names to target. Defaults to the local computer. Accepts pipeline input by value and by property name. .PARAMETER Days Look-back window in days. Valid values are 1 through 3650. Defaults to 7. .PARAMETER MaxEvents Maximum number of Schannel events read per machine. Valid values are 1 through 10000. Defaults to 200. .PARAMETER EventId Optional Schannel event IDs to query. Defaults to 36870, 36871, 36874, and 36888. .PARAMETER RemoteHost Optional case-insensitive filter applied after XML parsing to the extracted peer name. .PARAMETER Credential Optional PSCredential for authenticating to remote machines. Ignored for local machine queries. .EXAMPLE Get-SchannelError Returns Schannel errors from the local computer over the last seven days. .EXAMPLE Get-SchannelError -ComputerName 'SRV01' -Days 30 -RemoteHost 'api.example.com' Returns matching Schannel failures from SRV01 involving the specified peer. .EXAMPLE 'SRV01', 'SRV02' | Get-SchannelError -MaxEvents 500 -EventId 36888 Returns generated fatal TLS alerts from multiple computers through pipeline input. .OUTPUTS PSWinOps.SchannelError One object per parsed Schannel event, newest first, with normalized classification, extracted XML fields, a FailureCount for equivalent events, and a safe message. .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-09-04 Requires: PowerShell 5.1+ / Windows only Requires: Event Log Readers membership for remote or protected System log access Requires: WinRM enabled on target machines for remote queries Event IDs 36870, 36871, 36874, and 36888 are queried by default. Private keys, passwords, tokens, and secret fields are deliberately excluded from the output. .LINK https://github.com/k9fr4n/PSWinOps .LINK https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent #> [CmdletBinding(SupportsShouldProcess = $false, ConfirmImpact = 'None')] [OutputType('PSWinOps.SchannelError')] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'Name', 'DNSHostName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateRange(1, 3650)] [int]$Days = 7, [Parameter(Mandatory = $false)] [ValidateRange(1, 10000)] [int]$MaxEvents = 200, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [int[]]$EventId = @(36870, 36871, 36874, 36888), [Parameter(Mandatory = $false)] [AllowEmptyString()] [string]$RemoteHost = '', [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$Credential ) begin { Write-Verbose "[$($MyInvocation.MyCommand)] Starting Schannel error query" $startTime = (Get-Date).AddDays(-$Days) $scriptBlock = { param( [datetime]$ScanStartTime, [int]$MaxEvts, [int[]]$EventIdFilter, [string]$RemoteHostFilter ) $filter = @{ LogName = 'System' ProviderName = 'Schannel' Id = $EventIdFilter StartTime = $ScanStartTime } $eventRecords = @() try { $eventRecords = @(Get-WinEvent -FilterHashtable $filter -MaxEvents $MaxEvts -ErrorAction Stop) } catch { if ($_.Exception.Message -notmatch 'No events were found') { throw } } if ($eventRecords.Count -eq 0) { return } function Get-EventDataMap { param( [Parameter(Mandatory = $true)] [object]$Event ) $map = @{} try { $xml = [xml]$Event.ToXml() foreach ($node in @($xml.SelectNodes("//*[local-name()='Data']"))) { $name = [string]$node.GetAttribute('Name') if ([string]::IsNullOrWhiteSpace($name)) { continue } $map[$name] = [string]$node.InnerText } return [PSCustomObject]@{ Parsed = $true Data = $map } } catch { Write-Verbose "Could not parse Schannel event $($Event.Id) as XML: $_" return [PSCustomObject]@{ Parsed = $false Data = $map } } } function Get-EventField { param( [Parameter(Mandatory = $true)] [hashtable]$Data, [Parameter(Mandatory = $true)] [string[]]$Names ) foreach ($name in $Names) { if ($Data.ContainsKey($name) -and -not [string]::IsNullOrWhiteSpace([string]$Data[$name])) { return [string]$Data[$name] } } return '' } function ConvertTo-SafeMessage { param( [AllowEmptyString()] [string]$Message ) if ([string]::IsNullOrWhiteSpace($Message)) { return '' } $safeMessage = [regex]::Replace( $Message, '(?is)-----BEGIN [^-]+-----.*?-----END [^-]+-----', '[REDACTED-KEY]' ) $safeMessage = [regex]::Replace( $safeMessage, '(?im)\b(password|passwd|secret|token|private\s*key)\s*[:=]\s*[^;\r\n]+', '$1=[REDACTED]' ) return $safeMessage } function Get-NormalizedRole { param( [AllowEmptyString()] [string]$Value ) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } if ($Value -match '(?i)client') { return 'Client' } if ($Value -match '(?i)server') { return 'Server' } return $Value } function Get-Severity { param( [Parameter(Mandatory = $true)] [object]$Event ) if (-not [string]::IsNullOrWhiteSpace([string]$Event.LevelDisplayName)) { return [string]$Event.LevelDisplayName } switch ([int]$Event.Level) { 1 { return 'Critical' } 2 { return 'Error' } 3 { return 'Warning' } 4 { return 'Information' } 5 { return 'Verbose' } default { return 'Error' } } } $rows = [System.Collections.Generic.List[psobject]]::new() foreach ($eventRecord in @($eventRecords | Sort-Object -Property TimeCreated -Descending)) { $parsedEvent = Get-EventDataMap -Event $eventRecord $data = $parsedEvent.Data $eventId = [int]$eventRecord.Id $protocol = Get-EventField -Data $data -Names @( 'Protocol', 'ProtocolVersion', 'TlsVersion', 'TLSVersion', 'SslVersion' ) $alertDescription = Get-EventField -Data $data -Names @( 'AlertDescription', 'AlertDescriptionCode', 'Alert', 'AlertType' ) $errorState = Get-EventField -Data $data -Names @( 'ErrorState', 'InternalErrorState', 'State', 'ErrorCode', 'Error' ) $role = Get-NormalizedRole -Value (Get-EventField -Data $data -Names @( 'Role', 'ConnectionRole', 'EndpointRole', 'ClientServer', 'Direction' )) $remoteHostValue = Get-EventField -Data $data -Names @( 'RemoteHost', 'PeerName', 'PeerHost', 'TargetName', 'ServerName', 'ClientName' ) $certificateSubject = Get-EventField -Data $data -Names @( 'CertificateSubject', 'SubjectName', 'CertificateName', 'Subject' ) $cipherSuite = Get-EventField -Data $data -Names @( 'CipherSuite', 'Cipher', 'CipherAlgorithm', 'HashAlgorithm', 'ExchangeAlgorithm' ) $errorType = 'Unknown' if ($parsedEvent.Parsed) { if (-not [string]::IsNullOrWhiteSpace($cipherSuite)) { $errorType = 'Cipher' } elseif (-not [string]::IsNullOrWhiteSpace($alertDescription)) { $errorType = 'Alert' } elseif ($eventId -in @(36870, 36871)) { $errorType = 'Certificate' } elseif (-not [string]::IsNullOrWhiteSpace($protocol)) { $errorType = 'Protocol' } elseif ($eventId -in @(36874, 36888)) { $errorType = 'Alert' } } $eventTime = $eventRecord.TimeCreated $eventTimeValue = if ($null -eq $eventTime) { '' } else { $eventTime.ToString('o') } $rows.Add([PSCustomObject]@{ EventTime = $eventTime EventTimeValue = $eventTimeValue EventId = $eventId ErrorType = $errorType Severity = Get-Severity -Event $eventRecord Role = $role Protocol = $protocol AlertDescription = $alertDescription ErrorState = $errorState RemoteHost = $remoteHostValue CertificateSubject = $certificateSubject CipherSuite = $cipherSuite Message = ConvertTo-SafeMessage -Message ([string]$eventRecord.Message) }) } $eventTally = @{} foreach ($row in $rows) { $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}' -f ` $row.EventId, $row.ErrorType, $row.Role, $row.Protocol, $row.AlertDescription, $row.ErrorState, $row.RemoteHost, $row.CertificateSubject, $row.CipherSuite if ($eventTally.ContainsKey($key)) { $eventTally[$key]++ } else { $eventTally[$key] = 1 } } foreach ($row in ($rows | Sort-Object -Property EventTime -Descending)) { if (-not [string]::IsNullOrWhiteSpace($RemoteHostFilter) -and ([string]$row.RemoteHost).IndexOf($RemoteHostFilter, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue } $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}' -f ` $row.EventId, $row.ErrorType, $row.Role, $row.Protocol, $row.AlertDescription, $row.ErrorState, $row.RemoteHost, $row.CertificateSubject, $row.CipherSuite [PSCustomObject]@{ PSTypeName = 'PSWinOps.SchannelError' ComputerName = $env:COMPUTERNAME EventTime = $row.EventTimeValue EventId = $row.EventId ErrorType = $row.ErrorType Severity = $row.Severity Role = $row.Role Protocol = $row.Protocol AlertDescription = $row.AlertDescription ErrorState = $row.ErrorState RemoteHost = $row.RemoteHost CertificateSubject = $row.CertificateSubject FailureCount = $eventTally[$key] Message = $row.Message Timestamp = Get-Date -Format 'o' } } } } process { foreach ($targetComputer in $ComputerName) { try { Write-Verbose "[$($MyInvocation.MyCommand)] Querying Schannel errors on '$targetComputer'" Invoke-RemoteOrLocal -ComputerName $targetComputer -Credential $Credential ` -ScriptBlock $scriptBlock ` -ArgumentList @($startTime, $MaxEvents, $EventId, $RemoteHost) } catch { Write-Error "[$($MyInvocation.MyCommand)] Failed on '$targetComputer': $_" } } } end { Write-Verbose "[$($MyInvocation.MyCommand)] Completed Schannel error query" } } |