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
93 changes: 92 additions & 1 deletion health-tests/HealthTest-CertExpiry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,68 @@ function Get-CertExpiryFindingTitle {
return "$($Summary): Store='$Store'; Subject='$subject'; Issuer='$issuer'; SerialNumber='$serialNumber'"
}

function Get-CertExpiryEnhancedKeyUsageIds {
param([Parameter(Mandatory=$true)]$Certificate)

$usageIds = @()
$usageObjects = @(Get-CertExpiryPropertyValue -InputObject $Certificate -Name 'EnhancedKeyUsageList')
foreach ($usage in $usageObjects) {
if ($null -eq $usage) {
continue
}

$usageId = Get-CertExpiryPropertyValue -InputObject $usage -Name 'Value'
if ($null -eq $usageId) {
$objectId = Get-CertExpiryPropertyValue -InputObject $usage -Name 'ObjectId'
if ($null -ne $objectId) {
$usageId = Get-CertExpiryPropertyValue -InputObject $objectId -Name 'Value'
}
}

if ($null -ne $usageId -and -not [string]::IsNullOrWhiteSpace($usageId.ToString())) {
$usageIds += $usageId.ToString().Trim().ToLowerInvariant()
}
}

return @($usageIds | Sort-Object -Unique)
}

function Test-CertExpiryStrongReplacementCandidate {
param(
[Parameter(Mandatory=$true)]$Certificate,
[Parameter(Mandatory=$true)]$Candidate
)

$issuer = ConvertTo-CertExpiryFindingValue -Value (Get-CertExpiryPropertyValue -InputObject $Certificate -Name 'Issuer')
$candidateIssuer = ConvertTo-CertExpiryFindingValue -Value (Get-CertExpiryPropertyValue -InputObject $Candidate -Name 'Issuer')
if (-not [string]::Equals($issuer, $candidateIssuer, [System.StringComparison]::OrdinalIgnoreCase)) {
return $false
}

$hasPrivateKey = Get-CertExpiryPropertyValue -InputObject $Certificate -Name 'HasPrivateKey'
$candidateHasPrivateKey = Get-CertExpiryPropertyValue -InputObject $Candidate -Name 'HasPrivateKey'
if ($null -eq $hasPrivateKey -or $null -eq $candidateHasPrivateKey) {
return $false
}
if ([bool]$hasPrivateKey -ne [bool]$candidateHasPrivateKey) {
return $false
}

$usageIds = @(Get-CertExpiryEnhancedKeyUsageIds -Certificate $Certificate)
$candidateUsageIds = @(Get-CertExpiryEnhancedKeyUsageIds -Certificate $Candidate)
if ($usageIds.Count -ne $candidateUsageIds.Count) {
return $false
}

for ($index = 0; $index -lt $usageIds.Count; $index++) {
if ($usageIds[$index] -ne $candidateUsageIds[$index]) {
return $false
}
}

return $true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify SAN and key usage before lowering severity

When two certificates share the subject, issuer, private-key capability, and EKUs but have different Subject Alternative Name or Key Usage extensions, this function still returns true even though the candidate may not support the original certificate's hostnames or cryptographic operations. That causes the caller to downgrade an imminent-expiry failure to a warning, or a warning to a notice, for a certificate that has no functionally compatible replacement; compare these usage-defining extensions before classifying the candidate as strong.

Useful? React with 👍 / 👎.

}

function Get-CertExpiryReplacementCandidate {
param(
[Parameter(Mandatory=$true)]$Certificate,
Expand All @@ -131,6 +193,7 @@ function Get-CertExpiryReplacementCandidate {

$bestCandidate = $null
$bestCandidateNotAfter = [datetime]::MinValue
$bestCandidateIsStrong = $false
foreach ($candidate in $Certificates) {
$candidateSubject = ConvertTo-CertExpiryFindingValue -Value (Get-CertExpiryPropertyValue -InputObject $candidate -Name 'Subject')
if (-not [string]::Equals($candidateSubject, $subject, [System.StringComparison]::OrdinalIgnoreCase)) {
Expand Down Expand Up @@ -165,12 +228,20 @@ function Get-CertExpiryReplacementCandidate {
if ($candidateNotAfter -le $notAfter) {
continue
}
if ($candidateNotAfter -le $bestCandidateNotAfter) {

$candidateIsStrong = Test-CertExpiryStrongReplacementCandidate -Certificate $Certificate -Candidate $candidate
if ($null -ne $bestCandidate -and -not $candidateIsStrong -and $bestCandidateIsStrong) {
continue
}
if ($null -ne $bestCandidate -and
$candidateIsStrong -eq $bestCandidateIsStrong -and
$candidateNotAfter -le $bestCandidateNotAfter) {
continue
}

$bestCandidate = $candidate
$bestCandidateNotAfter = $candidateNotAfter
$bestCandidateIsStrong = $candidateIsStrong
}

return $bestCandidate
Expand All @@ -187,6 +258,7 @@ Tags: Essential
Uses: None.
The stable finding identity contains the store, subject, issuer, and serial number. It intentionally excludes the certificate thumbprint and volatile expiration details.
Expiration status, validity dates, private-key presence, enhanced key usages, a newer same-subject candidate, and remediation guidance are included in the finding comments.
When a newer valid same-subject candidate also has the same issuer, private-key capability, and enhanced key usages, it is a strong replacement candidate and the finding is lowered by one severity level. Bindings must still be checked.
Certificates expired for 60 days or more are notices because continued operation suggests that they were replaced or are no longer used; administrators must still verify bindings before removal.
Certificates with a total validity period of four days or less do not produce upcoming-expiry findings. Expired short-lived certificates are notices unless a newer currently valid certificate with the same subject exists, in which case they are included in an informational summary.
Expired Azure CRP certificates are summarized as informational noise only when a newer currently valid certificate with the same subject exists.
Expand Down Expand Up @@ -351,6 +423,19 @@ The certificate store has no universal reverse lookup for service or application
continue
}

$isStrongReplacementCandidate = $false
if ($null -ne $replacement) {
$isStrongReplacementCandidate = Test-CertExpiryStrongReplacementCandidate -Certificate $certificate -Candidate $replacement
if ($isStrongReplacementCandidate) {
if ($level -eq 'FAILURE') {
$level = 'WARNING'
}
elseif ($level -eq 'WARNING') {
$level = 'NOTICE'
}
}
}

$titleSummary = 'Certificate validity issue'
if ($status -like 'Expires in *') {
if ($level -eq 'FAILURE') {
Expand Down Expand Up @@ -404,13 +489,19 @@ The certificate store has no universal reverse lookup for service or application
$recommendedAction = 'Identify the service or application using this certificate and renew or replace it before expiration.'
}

$severityAdjustment = 'None.'
if ($isStrongReplacementCandidate) {
$severityAdjustment = 'Lowered one level because the candidate has the same issuer, private-key capability, and enhanced key usages.'
}

$commentLines = @(
"Status: $status",
"Validity: '$notBefore' through '$formattedNotAfter' (local time).",
"Friendly name: '$friendlyName'.",
"Has private key: $hasPrivateKey.",
"Enhanced key usages: $enhancedKeyUsages.",
"Newer valid same-subject candidate: $replacementText",
"Severity adjustment: $severityAdjustment",
'Binding check: Not performed because the Windows certificate store has no universal reverse lookup for references kept separately by services and applications. Confirm whether IIS, HTTP.sys, RDP, WinRM, SQL Server, Azure Backup, or another service references this certificate.',
"Recommended action: $recommendedAction"
)
Expand Down
63 changes: 63 additions & 0 deletions tests/Unit/HealthTest-CertExpiry.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,69 @@ Describe 'HealthTest-CertExpiry' {
$finding = @($script:warnings | Where-Object { $_ -match "SerialNumber='OLD'" })[0]
$finding | Should -Match "Newer valid same-subject candidate: Subject='CN=Shared'; Issuer='CN=New Issuer'; SerialNumber='NEW'"
$finding | Should -Match 'Confirm that required bindings moved before treating it as a replacement\.'
$finding | Should -Match '(?m)^Severity adjustment: None\.$'
}

It 'lowers severity by one level when a strong replacement candidate exists' {
$eku = [pscustomobject]@{ FriendlyName = 'Server Authentication'; Value = '1.3.6.1.5.5.7.3.1' }
Mock Get-ChildItem {
@(
New-TestCertificate -Subject 'CN=Renewed' -Issuer 'CN=Shared Issuer' -SerialNumber 'OLD' -NotAfter (Get-Date).AddDays(-5) -EnhancedKeyUsageList @($eku)
New-TestCertificate -Subject 'CN=Renewed' -Issuer 'CN=Shared Issuer' -SerialNumber 'NEW' -NotBefore (Get-Date).AddDays(-1) -NotAfter (Get-Date).AddYears(1) -EnhancedKeyUsageList @($eku)
)
}

HealthTest-CertExpiry

$finding = @($script:warnings | Where-Object { $_ -match "SerialNumber='OLD'" })[0]
$finding | Should -Match '^\[WARNING\] Certificate validity issue:'
$finding | Should -Match '(?m)^Severity adjustment: Lowered one level because the candidate has the same issuer, private-key capability, and enhanced key usages\.$'
}

It 'lowers an upcoming warning to notice for a strong replacement candidate' {
Mock Get-ChildItem {
@(
New-TestCertificate -Subject 'CN=Renewed Soon' -SerialNumber 'OLD' -NotAfter (Get-Date).AddDays(45)
New-TestCertificate -Subject 'CN=Renewed Soon' -SerialNumber 'NEW' -NotBefore (Get-Date).AddDays(-1) -NotAfter (Get-Date).AddYears(1)
)
}

HealthTest-CertExpiry

@($script:warnings | Where-Object { $_ -match "^\[NOTICE\].*SerialNumber='OLD'" }).Count | Should -Be 1
}

It 'does not lower severity when the candidate usage does not match' {
$serverEku = [pscustomobject]@{ FriendlyName = 'Server Authentication'; Value = '1.3.6.1.5.5.7.3.1' }
$clientEku = [pscustomobject]@{ FriendlyName = 'Client Authentication'; Value = '1.3.6.1.5.5.7.3.2' }
Mock Get-ChildItem {
@(
New-TestCertificate -Subject 'CN=Different Usage' -SerialNumber 'OLD' -NotAfter (Get-Date).AddDays(-5) -EnhancedKeyUsageList @($serverEku)
New-TestCertificate -Subject 'CN=Different Usage' -SerialNumber 'NEW' -NotBefore (Get-Date).AddDays(-1) -NotAfter (Get-Date).AddYears(1) -EnhancedKeyUsageList @($clientEku)
)
}

HealthTest-CertExpiry

$finding = @($script:warnings | Where-Object { $_ -match "SerialNumber='OLD'" })[0]
$finding | Should -Match '^\[FAILURE\] Certificate validity issue:'
$finding | Should -Match '(?m)^Severity adjustment: None\.$'
}

It 'prefers a strong candidate over a longer-lived weak candidate' {
Mock Get-ChildItem {
@(
New-TestCertificate -Subject 'CN=Several Candidates' -Issuer 'CN=Original Issuer' -SerialNumber 'OLD' -NotAfter (Get-Date).AddDays(-5)
New-TestCertificate -Subject 'CN=Several Candidates' -Issuer 'CN=Original Issuer' -SerialNumber 'STRONG' -NotBefore (Get-Date).AddDays(-1) -NotAfter (Get-Date).AddMonths(6)
New-TestCertificate -Subject 'CN=Several Candidates' -Issuer 'CN=Other Issuer' -SerialNumber 'WEAK' -NotBefore (Get-Date).AddDays(-1) -NotAfter (Get-Date).AddYears(2)
)
}

HealthTest-CertExpiry

$finding = @($script:warnings | Where-Object { $_ -match "SerialNumber='OLD'" })[0]
$finding | Should -Match '^\[WARNING\] Certificate validity issue:'
$finding | Should -Match "Newer valid same-subject candidate:.*SerialNumber='STRONG'"
}

It 'suppresses an expired Azure CRP certificate when a newer valid same-subject candidate exists' {
Expand Down