CertCheck.psm1
<# #> ######## # Global settings $ErrorActionPreference = "Stop" $InformationPreference = "Continue" Set-StrictMode -Version 2 Add-Type @" using System; using System.Net; using System.Net.Security; using System.Security.Cryptography.X509Certificates; public class ServerCertificateValidationCallback { public static bool IgnoreCertificateValidation(Object obj, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) { return true; } } "@ <# #> Function New-NormalisedUri { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] [OutputType([System.Uri])] param( [Parameter(Mandatory=$true)] [ValidateNotNull()] $UriObj, [Parameter(Mandatory=$false)] [switch]$AsString = $false ) process { $uri = $UriObj # Check for string without scheme prefix if ($uri.GetType().FullName -eq "System.String" -and $uri -notmatch "://") { # Is a string, but doesn't appear to have a scheme prefix $uri = "https://" + $uri } # If it's not a URI, attempt to convert to Uri directly if ($uri.GetType().FullName -ne "System.Uri") { try { $uri = [Uri]::New($uri) } catch { # Could not convert to Uri directly } } # If it's still not a URI, attempt to convert to Uri with a https:// prefix if ($uri.GetType().FullName -ne "System.Uri") { try { $uri = [Uri]::New("https://" + $uri) } catch { # Could not convert with https:// prefix } } # If it's still not a URI, then fail the normalisation if ($uri.GetType().FullName -ne "System.Uri") { Write-Error ("Failed to convert object to uri directly or with https:// prefix: {0}" -f $uri) } # Ensure the URI is lowercase and the path is absent $tempUri = [Uri]::New($uri.AbsoluteUri.ToLower()) $uri = [Uri]::New(("{0}://{1}:{2}" -f $tempUri.Scheme, $tempUri.Host, $tempUri.Port)) # Pass the Uri on if ($AsString) { $uri.ToString() } else { $uri } } } Function Get-CertificateExtension { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [OutputType('System.String')] [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Oid, [Parameter(Mandatory=$true)] [AllowNull()] [AllowEmptyCollection()] [PSCustomObject[]]$Extensions ) process { # Handle an empty extensions list if (($Extensions | Measure-Object).Count -eq 0) { [string]::Empty return } $content = $Extensions | Where-Object { $null -ne $_ -and $_.Oid -eq $Oid } | Select-Object -First 1 | ForEach-Object { $_.Value } if ([string]::IsNullOrEmpty($content)) { [string]::Empty } else { $content } } } Function Get-CertificateData { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNull()] [Uri]$Uri, [Parameter(Mandatory=$true)] [ValidateNotNull()] [string]$Sni, [Parameter(Mandatory=$false)] [ValidateNotNull()] [switch]$NoValidation, [Parameter(Mandatory=$false)] [ValidateNotNull()] [Int]$TimeoutSec = 10 ) process { # 'No Validation' callback $certValidation = { $true } # stream and client for disposal later $client = $null $stream = $null # Status object to report on connection, auth and cert $status = [PSCustomObject]@{ AuthSuccess = $false Connected = $false Certificate = $null Error = [string]::Empty } try { # Construct TcpClient and stream to target Write-Verbose ("{0}: Connecting" -f $Uri) $client = New-Object System.Net.Sockets.TcpClient # Tasks for connect and timeout $connect = $client.ConnectAsync($Uri.Host, $Uri.Port) $connect.Wait($TimeoutSec * 1000) | Out-Null # Check if we timed out if (!$connect.IsCompleted) { # Connect didn't finish in time Write-Error ("{0}: Failed to connect" -f $Uri) } # Update status $status.Connected = $true try { # Configure the SslStream connection $stream = $null if ($NoValidation) { $stream = New-Object System.Net.Security.SslStream -ArgumentList $client.GetStream(), $false, ([ServerCertificateValidationCallback]::IgnoreCertificateValidation) } else { $stream = New-Object System.Net.Security.SslStream -ArgumentList $client.GetStream(), $false } # This supplies the SNI to the endpoint Write-Verbose ("{0}: Sending SNI as {1}" -f $Uri, $Sni) $sslConnect = $stream.AuthenticateAsClientAsync($Sni) $sslConnect.Wait($TimeoutSec * 1000) | Out-Null if (!$sslConnect.IsCompleted) { # Connected but failed to perform TLS negotiation Write-Error "Failed to negotiate TLS with endpoint" } # Capture the remote certificate from the stream Write-Verbose ("{0}: Retrieving remote certificate" -f $Uri) $status.Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::New($stream.RemoteCertificate) # Update status $status.AuthSuccess = $true } catch { $status.Error = "Failed to negotiate TLS: $_" } finally { if ($null -ne $stream) { $stream.Dispose() } } } catch { $status.Error = "Failed to connect: $_" } finally { if ($null -ne $client) { $client.Dispose() } } # Return status object $status } } <# #> Function Test-EndpointCertificate { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '')] [CmdletBinding(DefaultParameterSetName="NoPipe")] param( [Parameter(Mandatory=$true, ValueFromPipeline, Position=0)] [ValidateNotNull()] $Connection, [Parameter(Mandatory=$false)] [ValidateNotNull()] [string]$Sni = "", [Parameter(Mandatory=$false)] [ValidateNotNull()] [Int]$ConcurrentChecks = 30, [Parameter(Mandatory=$false)] [ValidateNotNull()] [Int]$TimeoutSec = 10, [Parameter(Mandatory=$false)] [switch]$AsHashTable = $false, [Parameter(Mandatory=$false)] [ValidateNotNull()] [int]$LogProgressSec = 0 ) begin { # We'll verbose report on the total run time later on $beginTime = [DateTime]::UtcNow # Hang threshold $hangThresholdSec = $TimeoutSec * 3 if ($hangThresholdSec -lt 60) { $hangThresholdSec = 60 } # State object to assist with managing runspaces $state = [PSCustomObject]@{ runspaces = New-Object System.Collections.Generic.List[PSCustomObject] AsHashTable = $AsHashTable Completed = 0 Hung = 0 LastProgress = [DateTime]::UtcNow BeginTime = $beginTime LogProgressSec = $LogProgressSec TimeoutSec = $TimeoutSec HangThresholdSec = $hangThresholdSec } # Create a list of endpoints we've scheduled for checking to avoid duplicates # passed in by pipeline $scheduled = New-Object 'System.Collections.generic.HashSet[string]' } process { # Wait for job level to go under ConcurrentChecks Wait-CertCheckRunspaces -State $state -target ($ConcurrentChecks-1) # Object representing the target of the check $conn = [PSCustomObject]@{ Connection = $null Sni = $null } & { # Check if we have a Uri object if ($Connection.GetType().FullName -eq "System.Uri") { $conn.Connection = $Connection.AbsoluteUri.ToString() $conn.Sni = $Connection.Host.ToString() return } # If it's a HashTable, check for relevant keys if ($Connection.GetType().FullName -eq "System.Collections.Hashtable") { try { $conn.Connection = $Connection["Uri"].ToString() } catch {} try { $conn.Connection = $Connection["Connection"].ToString() } catch {} try { $conn.Sni = $Connection["Sni"].ToString() } catch {} return } # If it's a custom object, check for members if ($Connection.GetType().FullName -eq "System.Management.Automation.PSCustomObject") { try { $conn.Connection = $Connection.Uri.ToString() } catch {} try { $conn.Connection = $Connection.Connection.ToString() } catch {} try { $conn.Sni = $Connection.Sni.ToString() } catch {} return } # See if we can convert it to a uri object try { $uri = New-NormalisedUri $Connection # Success - Use this object $conn.Connection = $uri.ToString() $conn.Sni = $uri.Host.ToString() return } catch { } } # Normalise the Uri try { $conn.Connection = New-NormalisedUri $conn.Connection -AsString } catch { Write-Warning ("Could not normalise the Uri ({0}): {1}" -f $conn.Connection, $_) return } # Configure Sni, if there is a 'Connection' value, but no Sni if (![string]::IsNullOrEmpty($conn.Connection) -and [string]::IsNullOrEmpty($conn.Sni)) { try { $conn.Sni = ([Uri]::New($conn.Connection)).Host } catch { } } # If SNI was provided, unconditionally set the Sni to that, regardless of # what has been determined above if (![string]::IsNullOrEmpty($Sni)) { $conn.Sni = $Sni } # Make sure we have something valid to continue if ([string]::IsNullOrEmpty($conn.Connection) -or [string]::IsNullOrEmpty($conn.Sni)) { Write-Warning ("Could not convert incoming object or invalid inputs: {0}" -f $Connection) return } # Check if this combination of connection and uri is already in the scheduled list # and don't check, if it is already present $key = $conn.Connection + ":" + $conn.Sni if ($scheduled.Contains($key)) { return } # Record that we've scheduled a check for this $scheduled.Add($key) | Out-Null # Create a new runspace to perform the check for this connection and sni $runspace = New-CertCheckRunspace -Connection $conn.Connection -Sni $conn.Sni -TimeoutSec $TimeoutSec $state.runspaces.Add($runspace) } end { # Wait for all runspaces to finish Write-Verbose "Waiting for remainder of runspaces to finish" Wait-CertCheckRunspaces -State $state -Target 0 # Log progress completed Write-Progress -Id 1 -Activity "Endpoint Check" -Completed Write-Verbose ("Total runtime: {0} seconds" -f ([DateTime]::UtcNow - $beginTime).TotalSeconds) } } Function New-CertCheckRunspace { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Connection, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Sni, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [int]$TimeoutSec ) process { # Initial session state for the check script. This is to import functions in to the # creates runspaces $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() @("Get-CertificateData", "Get-CertificateExtension") | ForEach-Object { $content = Get-Content Function:\$_ | Out-String $function = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::New($_, $content) $initialSessionState.Commands.Add($function) } # Endpoint check script $checkScript = { param($Connection, $Sni, $TimeoutSec) $InformationPreference = "Continue" $ErrorActionPreference = "Stop" Set-StrictMode -Version 2 & { # Build status object $status = @{ Connection = $Connection Sni = $Sni Connected = $false Subject = "" Issuer = "" NotBefore = [DateTime]::MinValue NotAfter = [DateTime]::MinValue Thumbprint = "" LocallyTrusted = $false Extensions = "" SAN = "" EKU = "" BasicConstraints = "" RawData = "" Addresses = "" CertPath = "" ErrorMsg = "" } # Uri object for connection $uri = [Uri]$Connection $chain = $null try { # Get-CertificateData doesn't throw errors, just returns the status object # Attempt to connect with certificate validation on first attempt $connectStatus = Get-CertificateData -Uri $Connection -Sni $Sni -TimeoutSec $TimeoutSec # If we can't connect, just stop here if (!$connectStatus.Connected) { Write-Verbose "Could not connect to endpoint" Write-Error $connectStatus.Error } $failedAuth = $false if (!$connectStatus.AuthSuccess -or $null -eq $connectStatus.Certificate) { # We connected, but failed authentication or didn't receive a certificate # Attempt to reconnect, but without validation of certificates Write-Verbose "Validation of remote endpoint failed. Reattempting without validation." $connectStatus = Get-CertificateData -Uri $Connection -Sni $Sni -TimeoutSec $TimeoutSec -NoValidation $failedAuth = $true } if (!$connectStatus.AuthSuccess -or !$connectStatus.Connected -or $null -eq $connectStatus.Certificate -or ![string]::IsNullOrEmpty($connectStatus.Error)) { # Issue with connecting to the endpoint here. Can't continue Write-Verbose "Endpoint connectivity or auth failure" Write-Error $connectStatus.Error } $cert = $connectStatus.Certificate # Convert the extensions to friendly names with data Write-Verbose ("{0}: Unpacking certificate extensions" -f $Connection) try { $extensions = $cert.Extensions | Where-Object { $null -ne $_ } | ForEach-Object { $asndata = New-Object 'System.Security.Cryptography.AsnEncodedData' -ArgumentList $_.Oid, $_.RawData $friendlyName = [string]::Empty if (!([string]::IsNullOrEmpty($_.Oid.FriendlyName))) { $friendlyName = $_.Oid.FriendlyName } [PSCustomObject]@{ Oid = $_.Oid.Value FriendlyName = $friendlyName Value = $asndata.Format($false) } } } catch { Write-Error "Error unpacking extensions: $_" } # Pack the extensions in to a string object try { $extensionStr = ($extensions | ForEach-Object { ("{0}({1}) = {2}{3}" -f $_.FriendlyName, $_.Oid, $_.Value, [Environment]::NewLine) } | Out-String).TrimEnd([Environment]::NewLine) } catch { Write-Error "Error transforming extensions: $_" } # Get addresses for this endpoint $addresses = [System.Net.DNS]::GetHostAddresses($uri.Host) # Build chain information for this certificate $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::New() $chain.Build($cert) | Out-Null $certPath = ($chain.ChainElements | ForEach-Object { $_.Certificate.Subject.ToString() + [Environment]::NewLine } | Out-String).TrimEnd([Environment]::NewLine) # Update the hashtable with the entries we want to update on the CertificateInfo object Write-Verbose ("{0}: Updating object" -f $Connection) $status["Connected"] = $true $status["Subject"] = $cert.Subject $status["Issuer"] = $cert.Issuer $status["NotBefore"] = $cert.NotBefore.ToUniversalTime() $status["NotAfter"] = $cert.NotAfter.ToUniversalTime() $status["Thumbprint"] = $cert.Thumbprint $status["LocallyTrusted"] = !$failedAuth $status["Extensions"] = $extensionStr $status["SAN"] = Get-CertificateExtension -Oid "2.5.29.17" -Extensions $extensions $status["EKU"] = Get-CertificateExtension -Oid "2.5.29.37" -Extensions $extensions $status["BasicConstraints"] = Get-CertificateExtension -Oid "2.5.29.19" -Extensions $extensions $status["RawData"] = [System.Convert]::ToBase64String($cert.RawData) $addressStr = "" $addresses | ForEach-Object { $addressStr += ($_.ToString() + ", ") } $addressStr = $addressStr.TrimEnd(", ") $status["Addresses"] = $addressStr $status["CertPath"] = $certPath $status["ErrorMsg"] = [string]::Empty } catch { # Write-Warning ("{0}: Failed to check endpoint: {1}" -f $Connection, $_) $status["ErrorMsg"] = [string]$_ } finally { if ($null -ne $chain) { $chain.Dispose() } } # Return the state object $status } *>&1 } # Schedule a run for this uri Write-Verbose ("Scheduling check: {0}:{1}" -f $Connection, $Sni) $runspace = [PowerShell]::Create($initialSessionState) $runspace.AddScript($checkScript) | Out-Null $runspace.AddParameter("Connection", $Connection) | Out-Null $runspace.AddParameter("Sni", $Sni) | Out-Null $runspace.AddParameter("TimeoutSec", $TimeoutSec) | Out-Null [PSCustomObject]@{ Runspace = $runspace Status = $runspace.BeginInvoke() StartTime = [DateTime]::UtcNow Connection = $conn.Connection Sni = $conn.Sni } } } <# #> Function Wait-CertCheckRunspaces { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNull()] [PSCustomObject]$State, [Parameter(Mandatory=$true)] [ValidateNotNull()] [int]$Target ) process { while ($State.runspaces.Count -gt $Target) { # Separate tasks in to completed, in progress and hung $inProgressList = New-Object System.Collections.Generic.List[PSCustomObject] $completeList = New-Object System.Collections.Generic.List[PSCustomObject] $hungList = New-Object System.Collections.Generic.List[PSCustomObject] $State.runspaces | ForEach-Object { if ($_.Status.IsCompleted) { $completeList.Add($_) return } # If the runspace has been running less than the hang threshold, add to the list to review on next cycle if (([DateTime]::UtcNow - $_.StartTime).TotalSeconds -lt $State.HangThresholdSec) { $inProgressList.Add($_) return } # runspace is not complete and is considered to be hung as it has run over the threshold $hungList.Add($_) } # If nothing has completed, nothing else is in progress, so there are only hung runspaces left # then process the hung runspaces if ($completeList.Count -eq 0 -and $inProgressList.Count -eq 0 -and $hungList.Count -gt 0) { $hungList | ForEach-Object { Write-Warning ("Runspace for {0}:{1} has hung. Stopping." -f $_.Connection, $_.Sni) try { $_.Runspace.Stop() } catch { Write-Warning "Error stopping runspace: $_" } $_.Runspace.Dispose() $_.Runspace = $null $_.Status = $null Write-Warning ("Scheduling new runspace for {0}:{1}" -f $_.Connection, $_.Sni) $newRunspace = New-CertCheckRunspace -Connection $_.Connection -Sni $_.Sni -TimeoutSec $State.TimeoutSec $inProgressList.Add($newRunspace) } } else { # Otherwise, we'll just add them back in to the inprogress list $hungList | ForEach-Object { $inProgressList.Add($_) } } $State.runspaces = $inProgressList $State.Hung = $hungList.Count # Process completed runspaces $completeList | ForEach-Object { $runspace = $_ # Record completed job $State.Completed++ # Try to receive the job output, being the HashTable containing # the properties for the check try { $result = $runspace.Runspace.EndInvoke($runspace.Status) | ForEach-Object { # Filter out anything that isn't a hashtable, but report on it if ($_.GetType().FullName -ne "System.Collections.Hashtable") { Write-Warning "Runspace returned additional data: $_" } else { $_ } } # Make sure we have a single Hashtable $count = ($result | Measure-Object).Count if ($count -ne 1) { Write-Error "Runspace returned $count Hashtables, should be 1" } # Pass the Hashtable on in the pipeline if ($State.AsHashTable) { $result } else { [PSCustomObject]$result } } catch { Write-Warning "Error reading return from runspace: $_" Write-Warning ($_ | Format-List -property * | Out-String) } # Make sure we remove the runspace now $runspace.Runspace.Dispose() $runspace.Runspace = $null $runspace.Status = $null } # Progress status $status = New-CertCheckProgressMessage -State $State # Write progress update Write-Progress -Id 1 -Activity "Endpoint Check" -Status $status if ($State.LogProgressSec -gt 0 -and $State.LastProgress.AddSeconds($State.LogProgressSec) -lt [DateTime]::UtcNow) { $State.LastProgress = [DateTime]::UtcNow Write-Information "Endpoint Check Status: $status" } Start-Sleep -Seconds 1 } # If we're to log progress, the target is 0 and we've finished the wait loop, then log a final progress if ($State.LogProgressSec -gt 0 -and $target -eq 0) { $status = New-CertCheckProgressMessage -State $State Write-Information "Endpoint Check Status: $status" } } } <# #> Function New-CertCheckProgressMessage { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [ValidateNotNull()] [PSCustomObject]$State ) process { $runtime = ([DateTime]::UtcNow - $State.BeginTime).TotalSeconds $endpointsps = [Math]::Round($State.Completed / $runtime, 2) $status = ("Completed {0}/In Progress {1}/Hung {2}/Runtime {3} seconds/{4} p/s" -f $State.Completed, $State.runspaces.Count, $State.Hung, [Math]::Round($runtime, 2), $endpointsps) $status } } |