modules/Certificates/Certificates.psm1

<#
    Certificates.psm1 - local machine certificate stores (read-only).
    Produces: Certificates, CertificateBindings.
    NEVER exports certificates or private keys - metadata/booleans only.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='Certificates'; DisplayName='Certificates & PKI'; Category='Certificates'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('Certificates','CertificateBindings','CertificateAuthority')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    [pscustomobject]@{ ModuleName='Certificates'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $certs = [System.Collections.Generic.List[object]]::new()
    $bindings = [System.Collections.Generic.List[object]]::new()
    $now = Get-Date
    $stores = @('My','WebHosting','Remote Desktop','CA')
    foreach ($store in $stores) {
        try {
            $path = "Cert:\LocalMachine\$store"
            if (-not (Test-Path -LiteralPath $path)) { continue }
            foreach ($c in (Get-ChildItem -LiteralPath $path -ErrorAction SilentlyContinue)) {
                try {
                    $eku = ''
                    try { $eku = (($c.EnhancedKeyUsageList | ForEach-Object { $_.FriendlyName }) -join '; ') } catch { }
                    $certs.Add([pscustomobject]@{
                        StoreLocation='LocalMachine'; StoreName=$store
                        Subject=$c.Subject; Issuer=$c.Issuer; Thumbprint=$c.Thumbprint; FriendlyName=$c.FriendlyName
                        NotBefore=(Normalize-DateTime $c.NotBefore); NotAfter=(Normalize-DateTime $c.NotAfter)
                        EnhancedKeyUsage=$eku; HasPrivateKey=([bool]$c.HasPrivateKey)
                        SelfSigned=([bool]($c.Subject -eq $c.Issuer))
                        ExpiringSoon=([bool]($c.NotAfter -le $now.AddDays(90)))
                        DaysUntilExpiry=([int]([math]::Round(($c.NotAfter - $now).TotalDays,0)))
                    })
                } catch { }
            }
        } catch { Add-Limitation -Context $Context -Module 'Certificates' -Message ("Certificate store '{0}' read failed." -f $store) -Reason $_.Exception.Message | Out-Null }
    }

    # Correlate to IIS SSL bindings if the IIS module already produced them.
    try {
        if ($Context.DataSets.Contains('IisBindings')) {
            foreach ($b in @($Context.DataSets['IisBindings'].Rows)) {
                $hash = Get-RowValue -Row $b -Column 'CertificateHash'
                if ($hash) {
                    $bindings.Add([pscustomobject]@{ Usage='IIS'; BoundTo=(Get-RowValue -Row $b -Column 'Site'); Thumbprint=$hash; Detail=(Get-RowValue -Row $b -Column 'BindingInformation') })
                    Add-DependencyEdge -Context $Context -SourceType 'IIS Site' -SourceName (Get-RowValue -Row $b -Column 'Site') -DependencyType 'UsesCertificate' -Target $hash -Evidence 'SSL binding' -Confidence 'Confirmed' -SourceDataset 'IisBindings' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Can this certificate be exported or reissued on the target server?' | Out-Null
                }
            }
        }
    } catch { }

    # A CONFIGURED certificate authority (not merely the role): its CRL/AIA publication URLs
    # usually hard-code this server's name, and every issued certificate chains to it.
    $ca = [System.Collections.Generic.List[object]]::new()
    try {
        $cfgRoot = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration'
        $active = Get-RegistryValueSafe -Path $cfgRoot -Name 'Active'
        if ($active) {
            $p = Join-Path $cfgRoot $active
            $type = switch ([int](Get-RegistryValueSafe -Path $p -Name 'CAType')) { 0 {'Enterprise Root'} 1 {'Enterprise Subordinate'} 3 {'Standalone Root'} 4 {'Standalone Subordinate'} default {'Unknown'} }
            $urls = @(@(Get-RegistryValueSafe -Path $p -Name 'CRLPublicationURLs') + @(Get-RegistryValueSafe -Path $p -Name 'CACertPublicationURLs') | Where-Object { $_ } | ForEach-Object { ([string]$_ -replace '^\d+:','') })
            $names = @($env:COMPUTERNAME, (Get-RegistryValueSafe -Path $p -Name 'CAServerName')) | Where-Object { $_ }
            $ca.Add([pscustomobject]@{
                CAName=$active; CAType=$type; CAServerName=[string](Get-RegistryValueSafe -Path $p -Name 'CAServerName')
                ServiceStatus=[string](Get-Service CertSvc -ErrorAction SilentlyContinue).Status
                ValidityPeriod=("{0} {1}" -f (Get-RegistryValueSafe -Path $p -Name 'ValidityPeriodUnits'), (Get-RegistryValueSafe -Path $p -Name 'ValidityPeriod'))
                CrlPeriod=("{0} {1}" -f (Get-RegistryValueSafe -Path $p -Name 'CRLPeriodUnits'), (Get-RegistryValueSafe -Path $p -Name 'CRLPeriod'))
                PublicationUrls=($urls -join ' | ')
                # %1 is the CA server's DNS name, so http://%1/... hard-codes this server for every relying party.
                UrlsReferenceThisServer=[bool](@($urls | Where-Object { $_ -match '^(https?|file)://%1' }).Count -or @($names | Where-Object { $n = $_; $urls | Where-Object { $_ -match [regex]::Escape($n) } }).Count)
            })
            Add-DependencyEdge -Context $Context -SourceType 'Certificate Authority' -SourceName $active -DependencyType 'IssuesCertificates' -Target 'All certificate consumers' -Evidence 'AD CS configuration' -Confidence 'Confirmed' -SourceDataset 'CertificateAuthority' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Which systems hold certificates issued by this CA, and can the CA and its CRL/AIA locations be migrated or replaced?' | Out-Null
        }
    } catch { }

    return ,@{ Certificates=@($certs); CertificateBindings=@($bindings); CertificateAuthority=@($ca) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $certs = @(if ($RawData.Certificates) { @($RawData.Certificates) } else { @() })
    $bindings = @(if ($RawData.CertificateBindings) { @($RawData.CertificateBindings) } else { @() })
    Add-DataSet -Context $Context -Name 'Certificates' -Description 'Local machine certificates (no keys exported).' -Rows $certs -Visibility 'Internal' -SourceModule 'Certificates' | Out-Null
    Add-DataSet -Context $Context -Name 'CertificateBindings' -Description 'Certificate-to-service/site correlations.' -Rows $bindings -Visibility 'Internal' -SourceModule 'Certificates' | Out-Null
    Add-DataSet -Context $Context -Name 'CertificateAuthority' -Description 'Configured AD CS certification authority (registry config only).' -Rows @(if ($RawData.CertificateAuthority) { @($RawData.CertificateAuthority) } else { @() }) -Visibility 'Internal' -SourceModule 'Certificates' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets'