public/Get-MsecKeyVaultCertificate.ps1

function Get-MsecKeyVaultCertificate {
    <#
    .SYNOPSIS
        Every certificate in the accessible Key Vaults, with how long each has left - the
        certificate expiry inventory.

    .DESCRIPTION
        One row per certificate, across every vault the Az context can see, or a subset chosen
        by name or tag.

        CERTIFICATES ARE DATA-PLANE, SO RESOURCE GRAPH CANNOT SEE THEM. Every other Azure
        inventory in this module goes through Search-MsecAzureResourceGraph in one request;
        this one cannot. Resource Graph indexes the VAULT and nothing inside it -
        microsoft.keyvault/vaults/certificates returns no rows - so this walks the vaults with
        Az.KeyVault instead. That makes it the slowest command here, and the reason it takes
        -VaultName and -Tag: on a large estate you want to narrow it.

        A VAULT YOU CANNOT READ INTO IS NOT AN EMPTY VAULT, and telling them apart is the
        whole point of the Unreadable row. Listing vaults is a control-plane right (Reader);
        listing the certificates inside one is a data-plane right, granted separately through
        RBAC or an access policy. Having the first without the second is the NORMAL state for
        an auditor's account - so a vault that answers 403 emits a row saying so rather than
        contributing nothing, which would read as "this vault holds no certificates" and quietly
        shrink the inventory.

        EXPIRY IS BOTH AN OUTAGE AND A SECURITY QUESTION, the same as app registration
        credentials. An expired certificate breaks whatever presents it, usually at the worst
        moment; a very long-lived one is standing exposure if it leaks. DaysUntilExpiry answers
        the first and LifetimeDays the second.

        NO PRIVATE KEY IS READ. This lists metadata only - Get-AzKeyVaultCertificate returns
        the public certificate and its policy, never the key material.

    .PARAMETER VaultName
        Only these vaults, by name. Wildcards supported.

    .PARAMETER Tag
        Only vaults carrying this tag, as @{ Product = 'DNS' }. Matched case-insensitively on
        both key and value, because Azure treats tags that way and the Az cmdlets do not
        always.

    .PARAMETER ExpiringWithinDays
        Keep only certificates expiring within this many days. ALREADY-EXPIRED ones are always
        included, whatever the number: expired is strictly worse than expiring, and a window
        that hid them would answer the wrong question.

    .PARAMETER IncludeDisabled
        Include certificates whose current version is disabled. Excluded by default - a
        disabled certificate is not presented to anything, so its expiry is not an outage.

    .EXAMPLE
        Connect-AzAccount
        Get-MsecKeyVaultCertificate -Tag @{ Product = 'DNS' } | Sort-Object DaysUntilExpiry

    .EXAMPLE
        # The renewal list, worst first.
        Get-MsecKeyVaultCertificate -ExpiringWithinDays 60 |
            Sort-Object DaysUntilExpiry |
            Format-Table VaultName, Name, Subject, EndDateTime, DaysUntilExpiry, Issuer

    .EXAMPLE
        # Vaults the running identity cannot read into - fix these before trusting the count.
        Get-MsecKeyVaultCertificate | Where-Object Status -eq 'Unreadable'

    .OUTPUTS
        PSCustomObject per certificate, PSTypeName 'MsecKeyVaultCertificate'. A vault that
        could not be read emits one row with Status 'Unreadable'; an empty one, Status 'Empty'.

    .NOTES
        Uses your Az context, not the msec app session. Needs Reader on the vaults plus a
        data-plane grant - 'Key Vault Reader' (RBAC) or an access policy with certificate
        List/Get.

        SecretId is the URI the certificate's private material would be fetched from, which is
        what an App Service or Application Gateway binding references. It is a pointer, not the
        secret.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string[]] $VaultName,

        [hashtable] $Tag,

        [ValidateRange(0, 3650)]
        [int] $ExpiringWithinDays,

        [switch] $IncludeDisabled
    )

    if (-not (Get-Module -ListAvailable -Name Az.KeyVault)) {
        throw 'Az.KeyVault is required for Get-MsecKeyVaultCertificate. Install with: Install-Module Az.KeyVault -Scope CurrentUser'
    }
    Import-Module Az.KeyVault -ErrorAction Stop

    if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
        throw 'No Azure context. Run Connect-AzAccount, then Select-MsecAzureContext to pick the subscription to report on.'
    }

    $hasWindow = $PSBoundParameters.ContainsKey('ExpiringWithinDays')

    # One clock for the run: reading UtcNow per certificate would let a long enumeration
    # measure early vaults against a different 'now' than late ones.
    $now = [DateTime]::UtcNow

    # The API's timestamps come back as DateTime already, but not always Kind=Utc - and a
    # Local-kind value compared against a UTC clock is off by the offset, which moves
    # DaysUntilExpiry by a whole day either side of midnight.
    $toUtc = {
        param($value)
        if (-not $value) { return $null }
        if ($value -is [datetime]) {
            if ($value.Kind -eq [DateTimeKind]::Unspecified) {
                return [DateTime]::SpecifyKind($value, [DateTimeKind]::Utc)
            }
            return $value.ToUniversalTime()
        }
        [datetime]::Parse([string] $value, [cultureinfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor
            [System.Globalization.DateTimeStyles]::AssumeUniversal)
    }

    $vaults = @(Get-AzKeyVault -ErrorAction Stop)

    if ($VaultName) {
        $vaults = @($vaults | Where-Object {
            $name = $_.VaultName
            @($VaultName | Where-Object { $name -like $_ }).Count -gt 0
        })
    }

    if ($Tag) {
        # Case-insensitive on key AND value. Azure tags are case-insensitive; the Az objects
        # expose an ordinary hashtable that is not, so a vault tagged 'product=dns' would be
        # missed by -Tag @{ Product = 'DNS' } without this.
        $vaults = @($vaults | Where-Object {
            $vaultTags = [System.Collections.Generic.Dictionary[string, string]]::new([StringComparer]::OrdinalIgnoreCase)
            foreach ($key in @($_.Tags.Keys)) { $vaultTags[[string] $key] = [string] $_.Tags[$key] }

            $allMatch = $true
            foreach ($key in @($Tag.Keys)) {
                $value = $null
                if (-not $vaultTags.TryGetValue([string] $key, [ref] $value)) { $allMatch = $false; break }
                if ($value -ne [string] $Tag[$key]) { $allMatch = $false; break }
            }
            $allMatch
        })
    }

    if (-not $vaults.Count) {
        Write-Warning 'No Key Vaults matched. Check -VaultName / -Tag, and that the Az context is on the right subscription.'
        return
    }

    foreach ($vault in $vaults) {
        $common = [ordered]@{
            VaultName         = $vault.VaultName
            ResourceGroupName = $vault.ResourceGroupName
            Location          = $vault.Location
            VaultUri          = $vault.VaultUri
        }

        $certificates = $null
        try {
            $certificates = @(Get-AzKeyVaultCertificate -VaultName $vault.VaultName -ErrorAction Stop)
        }
        catch {
            # See the note in .DESCRIPTION: listing vaults and reading into them are different
            # rights, and having only the first is normal. This row is what stops that reading
            # as an empty vault.
            Write-Warning "Could not list the certificates in '$($vault.VaultName)', so it is reported as Unreadable rather than empty. The running identity needs a data-plane grant - 'Key Vault Reader' (RBAC) or an access policy with certificate List/Get. $($_.Exception.Message)"

            [PSCustomObject]($common + [ordered]@{
                PSTypeName      = 'MsecKeyVaultCertificate'
                Name            = $null
                Status          = 'Unreadable'
                Enabled         = $null
                Subject         = $null
                Issuer          = $null
                Thumbprint      = $null
                NotBefore       = $null
                EndDateTime     = $null
                DaysUntilExpiry = $null
                IsExpired       = $null
                LifetimeDays    = $null
                SecretId        = $null
                Id              = $null
            })
            continue
        }

        if (-not $certificates.Count) {
            # Genuinely empty, and said so rather than silently contributing nothing - the
            # difference from Unreadable above is the whole point.
            [PSCustomObject]($common + [ordered]@{
                PSTypeName      = 'MsecKeyVaultCertificate'
                Name            = $null
                Status          = 'Empty'
                Enabled         = $null
                Subject         = $null
                Issuer          = $null
                Thumbprint      = $null
                NotBefore       = $null
                EndDateTime     = $null
                DaysUntilExpiry = $null
                IsExpired       = $null
                LifetimeDays    = $null
                SecretId        = $null
                Id              = $null
            })
            continue
        }

        foreach ($listed in $certificates) {
            # The list call returns an identifier only; the subject, issuer and thumbprint
            # need the per-certificate GET. That makes this N+1 by necessity rather than by
            # oversight - there is no bulk form in the data plane.
            $certificate = $null
            try {
                $certificate = Get-AzKeyVaultCertificate -VaultName $vault.VaultName -Name $listed.Name -ErrorAction Stop
            }
            catch {
                Write-Warning "Could not read certificate '$($listed.Name)' from '$($vault.VaultName)': $($_.Exception.Message)"
                continue
            }
            if (-not $certificate) { continue }

            $enabled = $certificate.Enabled
            if (-not $IncludeDisabled -and $enabled -eq $false) { continue }

            $start = & $toUtc $certificate.NotBefore
            $end   = & $toUtc $certificate.Expires

            $days      = if ($end) { [int][Math]::Floor(($end - $now).TotalDays) } else { $null }
            # From the timestamps, not the floored day count: a certificate with nine hours
            # left floors to 0, and 0 must not read as expired.
            $isExpired = if ($end) { $end -lt $now } else { $null }
            $lifetime  = if ($start -and $end) { [int][Math]::Round(($end - $start).TotalDays) } else { $null }

            if ($hasWindow -and $end) {
                # Expired survives any window - see .PARAMETER ExpiringWithinDays.
                if (-not $isExpired -and $days -gt $ExpiringWithinDays) { continue }
            }

            [PSCustomObject]($common + [ordered]@{
                PSTypeName      = 'MsecKeyVaultCertificate'
                Name            = $certificate.Name
                Status          = 'Present'
                Enabled         = $enabled
                Subject         = $certificate.Certificate.Subject
                Issuer          = $certificate.Certificate.Issuer
                Thumbprint      = $certificate.Thumbprint
                NotBefore       = $start
                EndDateTime     = $end
                DaysUntilExpiry = $days
                IsExpired       = $isExpired
                LifetimeDays    = $lifetime
                # The URI a binding references - a pointer, not the secret itself.
                SecretId        = $certificate.SecretId
                Id              = $certificate.Id
            })
        }
    }
}