Public/Compare-CwSnapshot.ps1

function Compare-CwSnapshot {
    <#
    .SYNOPSIS
        Diffs two snapshots (or snapshot sets): new, removed and approaching
        certificate expiries.
    .DESCRIPTION
        Certificates are matched by host + thumbprint. 'Approaching' means the
        severity escalated between baseline and current run (OK->WARNING,
        WARNING->CRITICAL, ...) - exactly the certificates that moved closer to
        expiry in an actionable way since the last scan.
    .EXAMPLE
        $baseline = Get-CwSnapshot -Latest -Path .\snapshots -Skip 1
        $current = Get-CwSnapshot -Latest -Path .\snapshots
        Compare-CwSnapshot -ReferenceSnapshot $baseline -DifferenceSnapshot $current
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyCollection()]
        [object[]]$ReferenceSnapshot,

        [Parameter(Mandatory, Position = 1, ValueFromPipeline)]
        [object[]]$DifferenceSnapshot
    )

    begin {
        $diffSnaps = [System.Collections.Generic.List[object]]::new()
        $sevRank = @{ OK = 0; WARNING = 1; CRITICAL = 2; UNKNOWN = 0 }

        function Get-CertMap([object[]]$Snapshots) {
            $map = @{}
            foreach ($snap in $Snapshots) {
                foreach ($cert in @($snap.Certificates)) {
                    $map["$($snap.Host)|$($cert.Thumbprint)"] = [pscustomobject]@{
                        Host          = $snap.Host
                        Tenant        = if ($snap.PSObject.Properties['Tenant']) { $snap.Tenant } else { $null }
                        Subject       = $cert.Subject
                        Role          = $cert.Role
                        Thumbprint    = $cert.Thumbprint
                        NotAfter      = $cert.NotAfter
                        DaysRemaining = $cert.DaysRemaining
                        Severity      = $cert.Severity
                    }
                }
            }
            $map
        }
    }

    process {
        foreach ($snap in $DifferenceSnapshot) { $diffSnaps.Add($snap) }
    }

    end {
        $refMap = Get-CertMap -Snapshots $ReferenceSnapshot
        $diffMap = Get-CertMap -Snapshots $diffSnaps.ToArray()

        $new = foreach ($key in $diffMap.Keys) {
            if (-not $refMap.ContainsKey($key)) { $diffMap[$key] }
        }
        $removed = foreach ($key in $refMap.Keys) {
            if (-not $diffMap.ContainsKey($key)) { $refMap[$key] }
        }
        $approaching = foreach ($key in $diffMap.Keys) {
            if ($refMap.ContainsKey($key)) {
                $before = $refMap[$key]; $after = $diffMap[$key]
                if ($sevRank[[string]$after.Severity] -gt $sevRank[[string]$before.Severity]) {
                    $after | Select-Object *, @{ N = 'PreviousSeverity'; E = { $before.Severity } }
                }
            }
        }

        [pscustomobject]@{
            PSTypeName          = 'CertWatchtower.Trend'
            NewCertificates     = @($new | Sort-Object DaysRemaining)
            RemovedCertificates = @($removed | Sort-Object Host, Subject)
            Approaching         = @($approaching | Sort-Object DaysRemaining)
            Summary             = [pscustomobject]@{
                New         = @($new).Count
                Removed     = @($removed).Count
                Approaching = @($approaching).Count
            }
        }
    }
}