# .SYNOPSIS Windows Certificate Store Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for Windows certificate store -- monitors certificates across configurable store locations and names. Exports metrics including expiry time, key size, signature algorithm, and private key presence as Prometheus-compatible text format for windows_exporter textfile collector. .PARAMETER StoreLocations Comma-separated list of store locations to scan (default: LocalMachine) .PARAMETER StoreNames Comma-separated list of store names to scan (default: My,Root,CA,WebHosting) .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9197) .PARAMETER TextfileDir Directory for textfile collector output (default: C:\ProgramData\node_exporter) .PARAMETER InstallScheduledTask Switch to create a scheduled task for auto-start on system boot .PARAMETER TaskIntervalMinutes Interval in minutes for the scheduled task (default: 60) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 Metrics Exported: Core Status: - windows_cert_up - windows_cert_exporter_info{version} Certificate Details: - windows_cert_total{store_location,store_name} - windows_cert_expiry_seconds{store_location,store_name,subject,thumbprint,issuer} - windows_cert_expired_total{store_location,store_name} - windows_cert_expiring_soon_total{store_location,store_name} - windows_cert_self_signed_total{store_location,store_name} - windows_cert_key_size_bits{store_location,store_name,subject,thumbprint} - windows_cert_has_private_key{store_location,store_name,subject,thumbprint} - windows_cert_signature_algorithm{store_location,store_name,subject,thumbprint,algorithm} Exporter: - windows_cert_exporter_duration_seconds - windows_cert_exporter_last_run_timestamp #> param( [string]$StoreLocations = 'LocalMachine', [string]$StoreNames = 'My,Root,CA,WebHosting', [ValidateSet('stdout', 'textfile', 'http')] [string]$Mode = 'stdout', [int]$Port = 9197, [string]$TextfileDir = 'C:\ProgramData\node_exporter', [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 60 ) # Create a scheduled task to run this script every $TaskIntervalMinutes minutes # The task will run as SYSTEM and will be set to run at startup if ($InstallScheduledTask) { $taskName = "WindowsCertificateStoreExporter" $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $existingTask) { $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -Mode textfile" if (-not $TaskIntervalMinutes -or $TaskIntervalMinutes -le 0) { throw "TaskIntervalMinutes must be a positive integer" } $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes $TaskIntervalMinutes) -RepetitionDuration (New-TimeSpan -Days 365) $taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest try { Write-Host "Creating scheduled task: $taskName" Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Description "Exports Windows certificate store metrics for Prometheus every $TaskIntervalMinutes minutes" $createdTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $createdTask) { throw "Failed to verify scheduled task creation" } Write-Host "Successfully created scheduled task: $taskName" -ForegroundColor Green } catch { Write-Error "Failed to create scheduled task: $($_.Exception.Message)" throw } } else { Write-Host "Scheduled task '$taskName' already exists, skipping creation" } } $ErrorActionPreference = 'SilentlyContinue' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-UnixTimestamp { [int][double]::Parse((Get-Date -UFormat '%s')) } function Format-MetricValue { param([double]$Value, [int]$Decimals = 2) [math]::Round($Value, $Decimals) } function Get-CleanLabelValue { param([string]$Value, [int]$MaxLength = 80) if (-not $Value) { return '' } $Value = $Value -replace '"', '' $Value = $Value -replace '\\', '\\\\' if ($Value.Length -gt $MaxLength) { $Value = $Value.Substring(0, $MaxLength) } return $Value } function Get-CertCN { param([string]$DistinguishedName) if (-not $DistinguishedName) { return '' } if ($DistinguishedName -match 'CN=([^,]+)') { return $Matches[1].Trim() } return $DistinguishedName } # ============================================================================ # CERTIFICATE METRICS # ============================================================================ function Get-CertificateMetrics { $sb = [System.Text.StringBuilder]::new() $locations = $StoreLocations -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } $names = $StoreNames -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } $now = Get-Date # Collect all certificate data first $storeData = @() foreach ($location in $locations) { foreach ($name in $names) { $storeEntry = @{ Location = $location Name = $name Certs = @() ExpiredCount = 0 ExpiringSoonCount = 0 SelfSignedCount = 0 } try { $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($name, $location) $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) foreach ($cert in $store.Certificates) { $subjectRaw = Get-CertCN $cert.Subject $issuerRaw = Get-CertCN $cert.Issuer $subject = Get-CleanLabelValue $subjectRaw $issuer = Get-CleanLabelValue $issuerRaw $thumbprint = $cert.Thumbprint $expirySeconds = Format-MetricValue ($cert.NotAfter - $now).TotalSeconds $isExpired = $cert.NotAfter -lt $now $isExpiringSoon = (-not $isExpired) -and ($cert.NotAfter -lt $now.AddDays(30)) $isSelfSigned = ($cert.Subject -eq $cert.Issuer) $keySize = 0 if ($cert.PublicKey -and $cert.PublicKey.Key) { try { $keySize = $cert.PublicKey.Key.KeySize } catch {} } $hasPrivateKey = if ($cert.HasPrivateKey) { 1 } else { 0 } $sigAlgorithm = '' if ($cert.SignatureAlgorithm) { $sigAlgorithm = $cert.SignatureAlgorithm.FriendlyName if (-not $sigAlgorithm) { $sigAlgorithm = $cert.SignatureAlgorithm.Value } } $sigAlgorithm = Get-CleanLabelValue $sigAlgorithm if ($isExpired) { $storeEntry.ExpiredCount++ } if ($isExpiringSoon) { $storeEntry.ExpiringSoonCount++ } if ($isSelfSigned) { $storeEntry.SelfSignedCount++ } $storeEntry.Certs += @{ Subject = $subject Issuer = $issuer Thumbprint = $thumbprint ExpirySeconds = $expirySeconds KeySize = $keySize HasPrivateKey = $hasPrivateKey SigAlgorithm = $sigAlgorithm } } $store.Close() $store.Dispose() } catch { # Store does not exist or cannot be opened -- skip gracefully continue } $storeData += $storeEntry } } # --- windows_cert_total --- [void]$sb.AppendLine('# HELP windows_cert_total Total number of certificates in the store') [void]$sb.AppendLine('# TYPE windows_cert_total gauge') foreach ($entry in $storeData) { [void]$sb.AppendLine("windows_cert_total{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`"} $($entry.Certs.Count)") } [void]$sb.AppendLine('') # --- windows_cert_expiry_seconds --- [void]$sb.AppendLine('# HELP windows_cert_expiry_seconds Seconds until the certificate expires (negative means expired)') [void]$sb.AppendLine('# TYPE windows_cert_expiry_seconds gauge') foreach ($entry in $storeData) { foreach ($cert in $entry.Certs) { [void]$sb.AppendLine("windows_cert_expiry_seconds{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`",subject=`"$($cert.Subject)`",thumbprint=`"$($cert.Thumbprint)`",issuer=`"$($cert.Issuer)`"} $($cert.ExpirySeconds)") } } [void]$sb.AppendLine('') # --- windows_cert_expired_total --- [void]$sb.AppendLine('# HELP windows_cert_expired_total Count of expired certificates in the store') [void]$sb.AppendLine('# TYPE windows_cert_expired_total gauge') foreach ($entry in $storeData) { [void]$sb.AppendLine("windows_cert_expired_total{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`"} $($entry.ExpiredCount)") } [void]$sb.AppendLine('') # --- windows_cert_expiring_soon_total --- [void]$sb.AppendLine('# HELP windows_cert_expiring_soon_total Count of certificates expiring within 30 days') [void]$sb.AppendLine('# TYPE windows_cert_expiring_soon_total gauge') foreach ($entry in $storeData) { [void]$sb.AppendLine("windows_cert_expiring_soon_total{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`"} $($entry.ExpiringSoonCount)") } [void]$sb.AppendLine('') # --- windows_cert_self_signed_total --- [void]$sb.AppendLine('# HELP windows_cert_self_signed_total Count of self-signed certificates (subject equals issuer)') [void]$sb.AppendLine('# TYPE windows_cert_self_signed_total gauge') foreach ($entry in $storeData) { [void]$sb.AppendLine("windows_cert_self_signed_total{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`"} $($entry.SelfSignedCount)") } [void]$sb.AppendLine('') # --- windows_cert_key_size_bits --- [void]$sb.AppendLine('# HELP windows_cert_key_size_bits RSA or EC key size in bits') [void]$sb.AppendLine('# TYPE windows_cert_key_size_bits gauge') foreach ($entry in $storeData) { foreach ($cert in $entry.Certs) { [void]$sb.AppendLine("windows_cert_key_size_bits{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`",subject=`"$($cert.Subject)`",thumbprint=`"$($cert.Thumbprint)`"} $($cert.KeySize)") } } [void]$sb.AppendLine('') # --- windows_cert_has_private_key --- [void]$sb.AppendLine('# HELP windows_cert_has_private_key Whether the certificate has a private key (1=yes, 0=no)') [void]$sb.AppendLine('# TYPE windows_cert_has_private_key gauge') foreach ($entry in $storeData) { foreach ($cert in $entry.Certs) { [void]$sb.AppendLine("windows_cert_has_private_key{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`",subject=`"$($cert.Subject)`",thumbprint=`"$($cert.Thumbprint)`"} $($cert.HasPrivateKey)") } } [void]$sb.AppendLine('') # --- windows_cert_signature_algorithm --- [void]$sb.AppendLine('# HELP windows_cert_signature_algorithm Signature algorithm used by the certificate (info metric, always 1)') [void]$sb.AppendLine('# TYPE windows_cert_signature_algorithm gauge') foreach ($entry in $storeData) { foreach ($cert in $entry.Certs) { [void]$sb.AppendLine("windows_cert_signature_algorithm{store_location=`"$($entry.Location)`",store_name=`"$($entry.Name)`",subject=`"$($cert.Subject)`",thumbprint=`"$($cert.Thumbprint)`",algorithm=`"$($cert.SigAlgorithm)`"} 1") } } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # COLLECT ALL METRICS # ============================================================================ function Get-AllMetrics { $scriptStart = Get-Date $sb = [System.Text.StringBuilder]::new() # Exporter up [void]$sb.AppendLine('# HELP windows_cert_up Exporter status (1=up, 0=down)') [void]$sb.AppendLine('# TYPE windows_cert_up gauge') [void]$sb.AppendLine('windows_cert_up 1') [void]$sb.AppendLine('') # Exporter info [void]$sb.AppendLine('# HELP windows_cert_exporter_info Exporter version information') [void]$sb.AppendLine('# TYPE windows_cert_exporter_info gauge') [void]$sb.AppendLine('windows_cert_exporter_info{version="1.0"} 1') [void]$sb.AppendLine('') # Collect certificate metrics [void]$sb.Append((Get-CertificateMetrics)) # Exporter runtime $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds $timestamp = Get-UnixTimestamp [void]$sb.AppendLine('# HELP windows_cert_exporter_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE windows_cert_exporter_duration_seconds gauge') [void]$sb.AppendLine("windows_cert_exporter_duration_seconds $duration") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP windows_cert_exporter_last_run_timestamp Unix timestamp of last successful run') [void]$sb.AppendLine('# TYPE windows_cert_exporter_last_run_timestamp gauge') [void]$sb.AppendLine("windows_cert_exporter_last_run_timestamp $timestamp") [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # HTTP SERVER MODE # ============================================================================ function Start-HttpServer { param([int]$ListenPort) $prefix = "http://+:$ListenPort/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) try { $listener.Start() Write-Host "Starting Windows certificate store exporter on port $ListenPort..." -ForegroundColor Green Write-Host "Metrics available at http://localhost:$ListenPort/metrics" while ($listener.IsListening) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response if ($request.Url.AbsolutePath -eq '/metrics') { $metrics = Get-AllMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($metrics) $response.ContentType = 'text/plain; version=0.0.4; charset=utf-8' } else { $html = @"