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 } } } } |