Public/Get-CwCertInventory.ps1

function Get-CwCertInventory {
    <#
    .SYNOPSIS
        Reads local certificate stores and normalizes certificates into the
        cert-watchtower model including role classification.
    .DESCRIPTION
        Strictly read-only scan of the given stores (default LocalMachine\My).
        Each certificate is normalized (subject, thumbprint, validity, EKUs,
        template, SANs) and classified into an operational role
        (CA/LDAPS/RDP/IIS/Client/CodeSigning/Other). Severity is derived from
        the remaining lifetime; CA certificates get their own (longer)
        thresholds.
    .PARAMETER StoreName
        One or more store names under the store location. Default: My.
    .PARAMETER StoreLocation
        LocalMachine (default) or CurrentUser.
    .PARAMETER WarnDays
        Warning threshold in days for non-CA certificates. Default 30.
    .PARAMETER CritDays
        Critical threshold in days for non-CA certificates. Default 7.
    .PARAMETER CaWarnDays
        Warning threshold for CA certificates. Default 180.
    .PARAMETER CaCritDays
        Critical threshold for CA certificates. Default 30.
    .EXAMPLE
        Get-CwCertInventory -WarnDays 30 -CritDays 7 | Where-Object Severity -ne 'OK'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string[]]$StoreName = @('My'),

        [ValidateSet('LocalMachine', 'CurrentUser')]
        [string]$StoreLocation = 'LocalMachine',

        [int]$WarnDays = 30,
        [int]$CritDays = 7,
        [int]$CaWarnDays = 180,
        [int]$CaCritDays = 30,

        # AAD P2P certs (issuer MS-Organization-P2P-Access) are valid for ~1 day
        # BY DESIGN and rotate automatically - alerting on them is always a
        # false positive, so they are skipped unless explicitly included.
        [switch]$IncludeAutoRotating,

        # Additional issuer regex patterns to exclude (fleet noise tuning),
        # e.g. '-ExcludeIssuerPattern "CN=SMS Issuing"'.
        [string[]]$ExcludeIssuerPattern = @()
    )

    $isDc = $false
    try {
        $isDc = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).DomainRole -ge 4
    }
    catch {
        Write-Verbose "Could not determine domain role: $_"
    }

    $now = Get-Date
    foreach ($store in $StoreName) {
        $path = "Cert:\$StoreLocation\$store"
        if (-not (Test-Path -Path $path)) {
            Write-Warning "Certificate store '$path' not found - skipping."
            continue
        }

        foreach ($cert in (Get-ChildItem -Path $path)) {
            if ($cert -isnot [System.Security.Cryptography.X509Certificates.X509Certificate2]) { continue }

            if (-not $IncludeAutoRotating -and $cert.Issuer -match 'CN=MS-Organization-P2P-Access') {
                Write-Verbose "Skipping auto-rotating AAD P2P cert $($cert.Thumbprint) ($($cert.Subject))."
                continue
            }
            $excluded = $false
            foreach ($pattern in $ExcludeIssuerPattern) {
                if ($cert.Issuer -match $pattern) {
                    Write-Verbose "Skipping $($cert.Thumbprint) - issuer matches exclusion '$pattern'."
                    $excluded = $true
                    break
                }
            }
            if ($excluded) { continue }

            $ekuOids = @()
            $ekuNames = @()
            $isCa = $false
            $template = ''
            $sanNames = @()
            $cdpUrls = @()

            foreach ($ext in $cert.Extensions) {
                switch ($ext.Oid.Value) {
                    '2.5.29.37' {
                        $ekuOids = @($ext.EnhancedKeyUsages | ForEach-Object Value)
                        $ekuNames = @($ext.EnhancedKeyUsages | ForEach-Object {
                                if ($_.FriendlyName) { $_.FriendlyName } else { $_.Value } })
                    }
                    '2.5.29.19' { $isCa = $ext.CertificateAuthority }
                    '1.3.6.1.4.1.311.21.7' {
                        # msPKI-Certificate-Template: Format() text contains 'Template=<name>(...)'
                        if ($ext.Format($false) -match '(?:Template|Vorlage)\s*=\s*([^,(\r\n]+)') { $template = $Matches[1].Trim() }
                    }
                    '1.3.6.1.4.1.311.20.2' {
                        # legacy V1 certificate template name (BMPString)
                        if (-not $template) { $template = ($ext.Format($false)).Trim() }
                    }
                    '2.5.29.17' {
                        $sanNames = @([regex]::Matches($ext.Format($false), 'DNS[ -]?Name\s*=\s*([^,\s]+)') |
                            ForEach-Object { $_.Groups[1].Value })
                    }
                    '2.5.29.31' {
                        # CDP: where relying parties fetch the issuing CA's CRL
                        $cdpUrls = @([regex]::Matches($ext.Format($false), '(?:https?|ldap)://[^\s,;()]+') |
                            ForEach-Object { $_.Value })
                    }
                }
            }

            $role = Get-CwCertRole -EkuOids $ekuOids -IsCa $isCa -IsDomainController $isDc -TemplateName $template
            $daysRemaining = [math]::Round(($cert.NotAfter - $now).TotalDays, 2)

            $keyAlgorithm = $cert.PublicKey.Oid.FriendlyName
            try {
                $rsaKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($cert)
                if ($rsaKey) { $keyAlgorithm = "RSA $($rsaKey.KeySize)" }
                else {
                    $ecKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($cert)
                    if ($ecKey) { $keyAlgorithm = "ECDSA $($ecKey.KeySize)" }
                }
            }
            catch { Write-Verbose "Key algorithm detection failed for $($cert.Thumbprint): $_" }

            $sevParams = if ($role -eq 'CA') { @{ WarnDays = $CaWarnDays; CritDays = $CaCritDays } }
            else { @{ WarnDays = $WarnDays; CritDays = $CritDays } }

            [pscustomobject]@{
                PSTypeName    = 'CertWatchtower.Certificate'
                Subject       = $cert.Subject
                FriendlyName  = $cert.FriendlyName
                Issuer        = $cert.Issuer
                Thumbprint    = $cert.Thumbprint
                SerialNumber  = $cert.SerialNumber
                NotBefore     = $cert.NotBefore
                NotAfter      = $cert.NotAfter
                DaysRemaining = $daysRemaining
                HasPrivateKey = $cert.HasPrivateKey
                Role          = $role
                Template      = $template
                KeyAlgorithm  = $keyAlgorithm
                EkuOids       = $ekuOids
                EkuNames      = $ekuNames
                SanDnsNames   = $sanNames
                CdpUrls       = $cdpUrls
                IsCa          = $isCa
                Store         = "$StoreLocation\$store"
                Severity      = ConvertTo-CwSeverity -DaysRemaining $daysRemaining @sevParams
            }
        }
    }
}