Public/iis/Test-IISBindingCertificate.ps1
|
#Requires -Version 5.1 function Test-IISBindingCertificate { <# .SYNOPSIS Validates each IIS HTTPS binding certificate and emits a per-binding verdict (expiration, chain, hostname, key, store). .DESCRIPTION Inspects every https binding on one or more IIS hosts and evaluates the associated X509 certificate across six independent checks: expiration against configurable Warning/Critical thresholds, X509Chain.Build() validity, hostname/SAN match against the binding host header, private key availability, signature/key-algorithm strength, and alignment between the binding's declared CertStoreName and the store where the certificate is actually found. Each check contributes to a per-binding OverallStatus (Pass/Warning/Critical/Fail) and a Findings array describing every non-Pass condition. Complements Get-IISCertificateBinding (inventory) with an actionable verdict that IISAdministration does not expose. Falls back gracefully WebAdministration -> IISAdministration -> appcmd, supports multi-host execution via Invoke-RemoteOrLocal, and pipes cleanly from Get-IISCertificateBinding / Get-IISHealth. .PARAMETER ComputerName One or more computer names to query. Defaults to the local machine. Accepts pipeline input by value and by property name. .PARAMETER Credential Optional PSCredential for authenticating to remote computers. Not used for local queries. .PARAMETER SiteName Filter to one or more IIS site names. Supports -like wildcards. Defaults to all https-bound sites. .PARAMETER BindingInformation Filter to specific ip:port:hostheader bindings. Supports -like wildcards. Enables piping rows from Get-IISCertificateBinding for targeted re-tests. .PARAMETER HostHeader Filter on the host header part of the binding. Supports -like wildcards. .PARAMETER Thumbprint Filter to specific certificate thumbprints (40 hex characters, normalised upper-case internally). .PARAMETER WarningDays DaysUntilExpiration threshold below which ExpirationStatus becomes Warning. Must be greater than CriticalDays. Default: 30. .PARAMETER CriticalDays DaysUntilExpiration threshold below which ExpirationStatus becomes Critical. Default: 7. .PARAMETER MinKeySize Minimum acceptable RSA/DSA key size in bits. ECDSA keys are evaluated against a separate built-in baseline (>=256). Anything below MinKeySize flags AlgorithmStrength=Weak. Default: 2048. .PARAMETER SkipChainValidation Skip X509Chain.Build() -- useful on hosts without internet access to CRL/OCSP endpoints; ChainValid will be $null and ChainStatus empty. .PARAMETER AllowSelfSigned Do not downgrade OverallStatus when the chain ends in an untrusted root if the certificate is self-signed (Issuer equals Subject). .PARAMETER IncludeRevocationCheck Enable X509RevocationMode.Online during chain build (default is NoCheck for speed/offline-friendliness). .EXAMPLE Test-IISBindingCertificate Audit every HTTPS binding on the local host with default thresholds. .EXAMPLE 'WEB01','WEB02','WEB03' | Test-IISBindingCertificate -Credential (Get-Credential) Audit a web farm using alternate credentials. .EXAMPLE Test-IISBindingCertificate -ComputerName WEB01 | Where-Object OverallStatus -ne 'Pass' Surface only actionable verdicts. .EXAMPLE Test-IISBindingCertificate -ComputerName WEB01 -WarningDays 60 -CriticalDays 14 Tighten the expiration window for a renewal sweep. .EXAMPLE Test-IISBindingCertificate -ComputerName WEB01 -SkipChainValidation Skip chain build on an offline / air-gapped host. .EXAMPLE Get-IISCertificateBinding -ComputerName WEB01 -SiteName www | Test-IISBindingCertificate Re-test a specific binding piped from the inventory cmdlet. .EXAMPLE Test-IISBindingCertificate -ComputerName WEB01 -IncludeRevocationCheck Enable online revocation (CRL/OCSP) for a compliance run. .OUTPUTS PSCustomObject (PSTypeName='PSWinOps.IISCertificateBindingTestResult') .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-05-16 Requires: PowerShell 5.1+ / Windows only Requires: Web-Server (IIS) role Optional: Module WebAdministration or IISAdministration (falls back to appcmd) .LINK https://github.com/k9fr4n/PSWinOps .LINK https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.x509certificates.x509chain #> [CmdletBinding()] [OutputType('PSWinOps.IISCertificateBindingTestResult')] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'Name', 'DNSHostName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateNotNull()] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string[]]$SiteName, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string[]]$BindingInformation, [Parameter(Mandatory = $false)] [string[]]$HostHeader, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string[]]$Thumbprint, [Parameter(Mandatory = $false)] [ValidateRange(1, 3650)] [int]$WarningDays = 30, [Parameter(Mandatory = $false)] [ValidateRange(0, 3650)] [int]$CriticalDays = 7, [Parameter(Mandatory = $false)] [ValidateSet(1024, 2048, 3072, 4096)] [int]$MinKeySize = 2048, [Parameter(Mandatory = $false)] [switch]$SkipChainValidation, [Parameter(Mandatory = $false)] [switch]$AllowSelfSigned, [Parameter(Mandatory = $false)] [switch]$IncludeRevocationCheck ) begin { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting" if ($PSBoundParameters.ContainsKey('WarningDays') -and $PSBoundParameters.ContainsKey('CriticalDays')) { if ($WarningDays -le $CriticalDays) { throw [System.ArgumentException]::new( "WarningDays ($WarningDays) must be greater than CriticalDays ($CriticalDays)." ) } } $scriptBlock = { param( [string[]]$FilterSiteName, [string[]]$FilterBindingInformation, [string[]]$FilterHostHeader, [string[]]$FilterThumbprint, [int]$PWarningDays, [int]$PCriticalDays, [int]$PMinKeySize, [bool]$PSkipChainValidation, [bool]$PAllowSelfSigned, [bool]$PIncludeRevocationCheck ) $results = [System.Collections.Generic.List[hashtable]]::new() $tsNow = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $now = [datetime]::UtcNow # ── 1. Verify W3SVC / IIS installed ─────────────────────────────── try { $null = Get-Service -Name 'W3SVC' -ErrorAction Stop } catch { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = $null Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'IISNotInstalled' ErrorMessage = "W3SVC service not found: $($_.Exception.Message)" Timestamp = $tsNow }) return $results } # ── 2. Detect IIS module chain ───────────────────────────────────── $iisModule = $null if (Get-Module -Name 'WebAdministration' -ListAvailable -ErrorAction SilentlyContinue) { $iisModule = 'WebAdministration' } elseif (Get-Module -Name 'IISAdministration' -ListAvailable -ErrorAction SilentlyContinue) { $iisModule = 'IISAdministration' } # ── 3. Collect raw binding descriptors (https only) ──────────────── $bindingRows = [System.Collections.Generic.List[hashtable]]::new() if ($iisModule -eq 'WebAdministration') { try { Import-Module -Name 'WebAdministration' -ErrorAction Stop $allSites = @(Get-Website -ErrorAction Stop) if ($FilterSiteName) { $allSites = @($allSites | Where-Object { $sn = $_.Name $null -ne ($FilterSiteName | Where-Object { $sn -like $_ }) }) } foreach ($site in $allSites) { $httpsBindings = @( Get-WebBinding -Name $site.Name -Protocol 'https' -ErrorAction SilentlyContinue ) foreach ($b in $httpsBindings) { $rawTp = $b.certificateHash $tpStr = if ($rawTp) { ($rawTp -split '(?<=\G.{2})' | Where-Object { $_ -ne '' }) -join '' } else { $null } $bindingRows.Add(@{ SiteNameVal = $site.Name BindingInfoVal = $b.bindingInformation SslFlagsVal = [int]$b.sslFlags StoreNameVal = $b.certificateStoreName ThumbprintVal = if ($tpStr) { $tpStr.ToUpperInvariant() } else { $null } }) } } } catch { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = $null Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'Failed' ErrorMessage = "WebAdministration error: $($_.Exception.Message)" Timestamp = $tsNow }) return $results } } elseif ($iisModule -eq 'IISAdministration') { try { Import-Module -Name 'IISAdministration' -ErrorAction Stop $allSites = @(Get-IISSite -ErrorAction Stop) if ($FilterSiteName) { $allSites = @($allSites | Where-Object { $sn = $_.Name $null -ne ($FilterSiteName | Where-Object { $sn -like $_ }) }) } foreach ($site in $allSites) { foreach ($b in ($site.Bindings | Where-Object { $_.Protocol -eq 'https' })) { $rawHash = $b.CertificateHash $tpStr = if ($rawHash -and $rawHash.Count -gt 0) { ($rawHash | ForEach-Object { $_.ToString('X2') }) -join '' } else { $null } $sslFlagsInt = try { [int]$b.SslFlags } catch { 0 } $bindingRows.Add(@{ SiteNameVal = $site.Name BindingInfoVal = $b.BindingInformation SslFlagsVal = $sslFlagsInt StoreNameVal = $b.CertificateStoreName ThumbprintVal = if ($tpStr) { $tpStr.ToUpperInvariant() } else { $null } }) } } } catch { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = $null Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'Failed' ErrorMessage = "IISAdministration error: $($_.Exception.Message)" Timestamp = $tsNow }) return $results } } else { # appcmd fallback $appcmdExe = Join-Path -Path $env:windir -ChildPath 'system32\inetsrv\appcmd.exe' if (-not (Test-Path -LiteralPath $appcmdExe -PathType Leaf)) { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = $null Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'IISNotInstalled' ErrorMessage = 'Neither WebAdministration nor IISAdministration module is available, and appcmd.exe was not found.' Timestamp = $tsNow }) return $results } try { [xml]$sitesXml = & $appcmdExe list sites /config:* /xml 2>$null foreach ($siteNode in $sitesXml.appcmd.SITE) { $siteName = $siteNode.'SITE.NAME' if ($FilterSiteName) { $snMatch = $FilterSiteName | Where-Object { $siteName -like $_ } if (-not $snMatch) { continue } } foreach ($bNode in $siteNode.site.bindings.binding) { if ($bNode.protocol -ne 'https') { continue } $bindingRows.Add(@{ SiteNameVal = $siteName BindingInfoVal = $bNode.bindingInformation SslFlagsVal = 0 StoreNameVal = 'My' ThumbprintVal = $null }) } } # Enrich thumbprints via netsh sslcert $netshOut = & netsh http show sslcert 2>$null $netshBlock = ($netshOut -join "`n") $netshRx = [regex]'IP:port\s+:\s+(\S+)[\s\S]*?Certificate Hash\s+:\s+([0-9a-fA-F]+)' foreach ($nm in $netshRx.Matches($netshBlock)) { $epPort = ($nm.Groups[1].Value -split ':')[-1] $tpVal = $nm.Groups[2].Value.ToUpperInvariant() foreach ($row in $bindingRows) { $rParts = $row.BindingInfoVal -split ':' $rPort = if ($rParts.Count -ge 2) { $rParts[-2] } else { '' } if ($rPort -eq $epPort -and -not $row.ThumbprintVal) { $row.ThumbprintVal = $tpVal } } } } catch { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = $null Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'Failed' ErrorMessage = "appcmd fallback error: $($_.Exception.Message)" Timestamp = $tsNow }) return $results } } # ── 4. No bindings found ─────────────────────────────────────────── if ($bindingRows.Count -eq 0) { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = 'https' Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'BindingNotFound' ErrorMessage = 'No https bindings found on this host.' Timestamp = $tsNow }) return $results } # ── 5. Test each binding ────────────────────────────────────────── foreach ($br in $bindingRows) { $bindInfo = $br.BindingInfoVal # Parse BindingInformation: ip:port:hostheader (handles IPv6) $bParts = $bindInfo -split ':' $bHostHeader = '' $bPort = 443 if ($bParts.Count -ge 2) { $bHostHeader = $bParts[-1] $bPort = [int]$bParts[-2] } # Normalise SslFlags $sslFlagsStr = switch ($br.SslFlagsVal) { 0 { 'None' } 1 { 'Sni' } 2 { 'CentralCertStore' } 3 { 'Sni+CentralCertStore' } default { $br.SslFlagsVal.ToString() } } # Apply filters if ($FilterBindingInformation) { $bim = $bindInfo $bimMatch = $FilterBindingInformation | Where-Object { $bim -like $_ } if (-not $bimMatch) { continue } } if ($FilterHostHeader) { $bhhLocal = $bHostHeader $bhhMatch = $FilterHostHeader | Where-Object { $bhhLocal -like $_ } if (-not $bhhMatch) { continue } } $thumbprintUp = if ($br.ThumbprintVal) { $br.ThumbprintVal.ToUpperInvariant() } else { '' } if ($FilterThumbprint) { $tpMatch = $FilterThumbprint | Where-Object { $thumbprintUp -ieq $_ } if (-not $tpMatch) { continue } } $certStoreName = if ($br.StoreNameVal) { $br.StoreNameVal } else { 'My' } # ── Find certificate in LocalMachine stores ─────────────────── $cert = $null $actualStore = $null $storeAligned = $false if ($thumbprintUp) { foreach ($storeName in @('My', 'WebHosting', 'CA', 'Root')) { try { $store = [System.Security.Cryptography.X509Certificates.X509Store]::new( $storeName, [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine ) $store.Open( [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly ) $found = $store.Certificates | Where-Object { $_.Thumbprint -ieq $thumbprintUp } | Select-Object -First 1 $store.Close() if ($found) { $cert = $found $actualStore = "LocalMachine\$storeName" $storeAligned = ($storeName -ieq $certStoreName) break } } catch { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Could not search store '$storeName': $_" } } } if (-not $cert) { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $br.SiteNameVal BindingInformation = $bindInfo Protocol = 'https' Port = $bPort HostHeader = $bHostHeader SslFlags = $sslFlagsStr Thumbprint = $thumbprintUp Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'CertNotFound' ErrorMessage = "Certificate with thumbprint '$thumbprintUp' not found in any LocalMachine store." Timestamp = $tsNow }) continue } $findings = [System.Collections.Generic.List[string]]::new() # ── Check 1 : Expiration ────────────────────────────────────── $notBefore = $cert.NotBefore $notAfter = $cert.NotAfter $daysLeft = [int][math]::Floor(($notAfter.ToUniversalTime() - $now).TotalDays) $expirationStatus = if ($notAfter.ToUniversalTime() -le $now) { [void]$findings.Add("Certificate expired on $($notAfter.ToString('yyyy-MM-dd HH:mm:ss'))") 'Expired' } elseif ($notBefore.ToUniversalTime() -gt $now) { [void]$findings.Add("Certificate not yet valid (NotBefore: $($notBefore.ToString('yyyy-MM-dd HH:mm:ss')))") 'NotYetValid' } elseif ($daysLeft -le $PCriticalDays) { [void]$findings.Add("Certificate expires in $daysLeft day(s) (Critical threshold: $PCriticalDays)") 'Critical' } elseif ($daysLeft -le $PWarningDays) { [void]$findings.Add("Certificate expires in $daysLeft day(s) (Warning threshold: $PWarningDays)") 'Warning' } else { 'OK' } # ── Check 2 : Chain ─────────────────────────────────────────── $chainValid = $null $chainStatus = @() if (-not $PSkipChainValidation) { try { $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() $chain.ChainPolicy.RevocationMode = if ($PIncludeRevocationCheck) { [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online } else { [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck } $chainValid = $chain.Build($cert) if (-not $chainValid) { $chainStatus = @( $chain.ChainStatus | ForEach-Object { $_.Status.ToString() } ) $isSelfSigned = ($cert.Subject -eq $cert.Issuer) $onlyUntrustedRoot = ( $chainStatus.Count -gt 0 -and ($chainStatus | Where-Object { $_ -ne 'UntrustedRoot' }).Count -eq 0 ) if ($PAllowSelfSigned -and $isSelfSigned -and $onlyUntrustedRoot) { $chainValid = $true $chainStatus = @() } else { [void]$findings.Add("Chain build failed: $($chainStatus -join ', ')") } } $chain.Dispose() } catch { $chainValid = $false $chainStatus = @("Exception: $($_.Exception.Message)") [void]$findings.Add("Chain validation threw an exception: $($_.Exception.Message)") } } # ── Check 3 : Hostname match ────────────────────────────────── $hostnameMatch = $true if (-not [string]::IsNullOrEmpty($bHostHeader)) { $nameToTest = $bHostHeader.ToLowerInvariant() $certNames = [System.Collections.Generic.List[string]]::new() $cnRegex = [regex]::Match($cert.Subject, '(?i)CN=([^,]+)') if ($cnRegex.Success) { [void]$certNames.Add($cnRegex.Groups[1].Value.Trim()) } $sanExtension = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq 'Subject Alternative Name' } if ($sanExtension) { $sanExtension.Format($false) -split ', ' | Where-Object { $_ -like 'DNS Name=*' } | ForEach-Object { [void]$certNames.Add(($_ -split '=', 2)[1]) } } $hostnameMatch = $false foreach ($certName in $certNames) { $p = $certName.ToLowerInvariant() if ($p.StartsWith('*.')) { $suffix = $p.Substring(1) if ($nameToTest.EndsWith($suffix)) { $label = $nameToTest.Substring(0, $nameToTest.Length - $suffix.Length) if ($label -notmatch '\.') { $hostnameMatch = $true break } } } elseif ($nameToTest -eq $p) { $hostnameMatch = $true break } } if (-not $hostnameMatch) { [void]$findings.Add( "Hostname '$bHostHeader' does not match any certificate CN or SAN ($($certNames -join ', '))" ) } } # ── Check 4 : Private key ───────────────────────────────────── $hasPrivateKey = $cert.HasPrivateKey if (-not $hasPrivateKey) { [void]$findings.Add('Certificate does not have an associated private key in this store') } # ── Check 5 : Algorithm strength ────────────────────────────── $sigAlgFriendly = $cert.SignatureAlgorithm.FriendlyName $keyAlgOid = $cert.PublicKey.Oid.FriendlyName $keyAlg = switch ($keyAlgOid) { 'RSA' { 'RSA' } 'ECC' { 'ECDSA' } 'DSA' { 'DSA' } default { $keyAlgOid } } $keySize = 0 try { if ($keyAlg -eq 'ECDSA') { $ecKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($cert) if ($ecKey) { $keySize = $ecKey.KeySize } } else { $keySize = $cert.PublicKey.Key.KeySize } } catch { $keySize = 0 } $sigHash = if ($sigAlgFriendly -imatch 'sha512') { 'SHA512' } elseif ($sigAlgFriendly -imatch 'sha384') { 'SHA384' } elseif ($sigAlgFriendly -imatch 'sha256') { 'SHA256' } elseif ($sigAlgFriendly -imatch 'sha1') { 'SHA1' } elseif ($sigAlgFriendly -imatch 'md5') { 'MD5' } else { 'Unknown' } $goodHash = $sigHash -in @('SHA256', 'SHA384', 'SHA512') $algStrength = if ($sigHash -in @('MD5', 'SHA1', 'Unknown') -or $keySize -eq 0) { 'Weak' } elseif ($keyAlg -eq 'ECDSA') { if ($keySize -ge 384 -and $goodHash) { 'Strong' } elseif ($keySize -ge 256 -and $goodHash) { 'Acceptable' } else { 'Weak' } } elseif ($keyAlg -in @('RSA', 'DSA')) { if ($keySize -lt $PMinKeySize) { 'Weak' } elseif ($keySize -ge 3072 -and $goodHash) { 'Strong' } elseif ($keySize -ge 2048 -and $goodHash) { 'Acceptable' } else { 'Weak' } } else { 'Weak' } if ($algStrength -eq 'Weak') { [void]$findings.Add( "Algorithm strength Weak: $keyAlg $keySize-bit, signature $sigAlgFriendly" ) } elseif ($algStrength -eq 'Acceptable') { [void]$findings.Add( "Algorithm strength Acceptable: $keyAlg $keySize-bit (consider upgrading to 3072+)" ) } # ── Check 6 : Store alignment ───────────────────────────────── if (-not $storeAligned) { [void]$findings.Add( "Store misalignment: binding declares '$certStoreName' but certificate found in '$actualStore'" ) } # ── Collect SANs for output ─────────────────────────────────── $outSANs = @() $sanExtOut = $cert.Extensions | Where-Object { $_.Oid.FriendlyName -eq 'Subject Alternative Name' } if ($sanExtOut) { $outSANs = @( $sanExtOut.Format($false) -split ', ' | Where-Object { $_ -like 'DNS Name=*' } | ForEach-Object { ($_ -split '=', 2)[1] } ) } # ── Compute OverallStatus ───────────────────────────────────── $isCriticalExp = $expirationStatus -in @('Critical', 'Expired', 'NotYetValid') $chainFail = ($null -ne $chainValid) -and (-not $chainValid) $overallStatus = if ( $isCriticalExp -or (-not $hostnameMatch) -or (-not $hasPrivateKey) -or $chainFail -or ($algStrength -eq 'Weak') -or (-not $storeAligned) ) { 'Critical' } elseif ($expirationStatus -eq 'Warning' -or $algStrength -eq 'Acceptable') { 'Warning' } else { 'Pass' } $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $br.SiteNameVal BindingInformation = $bindInfo Protocol = 'https' Port = $bPort HostHeader = $bHostHeader SslFlags = $sslFlagsStr Thumbprint = $thumbprintUp Subject = $cert.Subject SubjectAlternativeName = $outSANs Issuer = $cert.Issuer NotBefore = $notBefore NotAfter = $notAfter DaysUntilExpiration = $daysLeft ExpirationStatus = $expirationStatus ChainValid = $chainValid ChainStatus = $chainStatus HostnameMatch = $hostnameMatch HasPrivateKey = $hasPrivateKey SignatureAlgorithm = $sigAlgFriendly KeyAlgorithm = $keyAlg KeySize = $keySize AlgorithmStrength = $algStrength CertificateStore = $actualStore StoreAligned = $storeAligned OverallStatus = $overallStatus Findings = @($findings) Status = 'Tested' ErrorMessage = $null Timestamp = $tsNow }) } if ($results.Count -eq 0) { $results.Add(@{ ComputerName = $env:COMPUTERNAME SiteName = $null; BindingInformation = $null; Protocol = 'https' Port = $null; HostHeader = $null; SslFlags = $null Thumbprint = $null; Subject = $null; SubjectAlternativeName = @() Issuer = $null; NotBefore = $null; NotAfter = $null DaysUntilExpiration = $null; ExpirationStatus = $null ChainValid = $null; ChainStatus = @() HostnameMatch = $null; HasPrivateKey = $null SignatureAlgorithm = $null; KeyAlgorithm = $null; KeySize = $null AlgorithmStrength = $null; CertificateStore = $null; StoreAligned = $null OverallStatus = 'Fail'; Findings = @() Status = 'BindingNotFound' ErrorMessage = 'No https bindings matched the supplied filters on this host.' Timestamp = $tsNow }) } return $results } } process { foreach ($cn in $ComputerName) { $tpNorm = if ($Thumbprint) { @($Thumbprint | ForEach-Object { $_.ToUpperInvariant() }) } else { $null } $invokeParams = @{ ComputerName = $cn ScriptBlock = $scriptBlock ArgumentList = @( $SiteName, $BindingInformation, $HostHeader, $tpNorm, $WarningDays, $CriticalDays, $MinKeySize, $SkipChainValidation.IsPresent, $AllowSelfSigned.IsPresent, $IncludeRevocationCheck.IsPresent ) } if ($Credential) { $invokeParams['Credential'] = $Credential } try { $rawResults = Invoke-RemoteOrLocal @invokeParams foreach ($r in $rawResults) { [PSCustomObject]([ordered]@{ PSTypeName = 'PSWinOps.IISCertificateBindingTestResult' ComputerName = $r.ComputerName SiteName = $r.SiteName BindingInformation = $r.BindingInformation Protocol = $r.Protocol Port = $r.Port HostHeader = $r.HostHeader SslFlags = $r.SslFlags Thumbprint = $r.Thumbprint Subject = $r.Subject SubjectAlternativeName = $r.SubjectAlternativeName Issuer = $r.Issuer NotBefore = $r.NotBefore NotAfter = $r.NotAfter DaysUntilExpiration = $r.DaysUntilExpiration ExpirationStatus = $r.ExpirationStatus ChainValid = $r.ChainValid ChainStatus = $r.ChainStatus HostnameMatch = $r.HostnameMatch HasPrivateKey = $r.HasPrivateKey SignatureAlgorithm = $r.SignatureAlgorithm KeyAlgorithm = $r.KeyAlgorithm KeySize = $r.KeySize AlgorithmStrength = $r.AlgorithmStrength CertificateStore = $r.CertificateStore StoreAligned = $r.StoreAligned OverallStatus = $r.OverallStatus Findings = $r.Findings Status = $r.Status ErrorMessage = $r.ErrorMessage Timestamp = $r.Timestamp }) } } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '$cn': $_" } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |