AzureAppGWCertificateMetaData.ps1

<#PSScriptInfo
 
.VERSION 1.0.0
 
.GUID 1d923b03-23e2-4bb9-91d2-b9501cedb742
 
.AUTHOR Brandyn Koehler
 
.COMPANYNAME b-rito
 
.COPYRIGHT (c) 2025 Brandyn Koehler
 
.TAGS Azure Az ApplicationGateway AzNetworking Certificates Metadata
 
.LICENSEURI https://opensource.org/licenses/MIT
 
.PROJECTURI https://github.com/b-rito/PowerShell/Azure/ApplicationGateway/AzureAppGWCertificateMetaData.ps1
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
1.0.0
 - Gather certificate metadata for Application Gateway resources
 - Retrieves certificate chain details for listener (Key Vault–backed when associated)
 
.PRIVATEDATA
 
#>


#Requires -Module Az.Network
#Requires -Module Az.Resources
#Requires -Module Az.Accounts

<#
 
.SYNOPSIS
Collects Azure Application Gateway listener certificate metadata (thumbprints, CN, SANs, issuer, validity).
 
.DESCRIPTION
This single-file script queries one or more Azure Application Gateways and returns formatted certificate
metadata for listener certificates, including full chain information when available. Supports both
targeting a specific gateway by ResourceId and scanning gateways across a subscription (optionally
limited to a resource group). Output can be written to console or saved to a file. Transcript logging
is available for troubleshooting.
 
.PARAMETER ResourceId
The Application Gateway resource ID.
Example: "/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.Network/applicationGateways/<appgw-name>"
 
.PARAMETER SubscriptionId
The Azure subscription ID to scan for Application Gateways when not using -ResourceId.
 
.PARAMETER ResourceGroupName
(Optional) The resource group name to limit scanning when using -SubscriptionId.
 
.PARAMETER OutputFile
(Optional) File path for saving formatted certificate metadata (UTF-8). If omitted, writes to console.
 
.PARAMETER TranscriptLog
(Optional) Switch. When provided, starts a transcript in the current directory and stops it on completion.
 
.EXAMPLE
.\AppGWCertificateMetadata.ps1 -ResourceId "/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.Network/applicationGateways/<AppGw>"
 
.EXAMPLE
.\AppGWCertificateMetadata.ps1 -SubscriptionId "<sub-id>" -OutputFile ".\AppGWCertMetaData.txt"
 
.EXAMPLE
.\AppGWCertificateMetadata.ps1 -SubscriptionId "<sub-id>" -ResourceGroupName "<rg>" -OutputFile ".\AppGWCertMetaData.txt" -TranscriptLog
 
.INPUTS
String
 
.OUTPUTS
String
 
.NOTES
Requires Az context permissions to read Application Gateway resources and invoke ARM REST API operations.
Non-interactive environments (e.g., CI) are auto-accepted for bulk operations.
 
.LINK
Project: https://github.com/b-rito/PowerShell/Azure/ApplicationGateway/AzureAppGWCertificateMetaData.ps1
Docs: https://github.com/b-rito/PowerShell/Azure/ApplicationGateway/AzureAppGWCertificateMetaData.ps1
#>


#Requires -Version 5.1

Param(
    [Parameter(ParameterSetName = "One", Mandatory=$True, Position = 0)][string] $ResourceId,
    [Parameter(ParameterSetName = "Many", Mandatory=$True, Position = 0)][string] $SubscriptionId,
    [Parameter(ParameterSetName = "Many", Mandatory=$False)][string] $ResourceGroupName,
    [Parameter(Mandatory=$False)][string] $OutputFile,
    [Parameter(Mandatory=$False)][switch] $TranscriptLog
)

# For additional output of all done
if ($TranscriptLog) {
    $transcriptPath = "AppGWCertMetaData_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
    Start-Transcript -Path $transcriptPath
}

# Validate Modules installed
if (!(Get-Module -ListAvailable -Name Az.Network))
{
    Write-Error "You need 'Az' module to proceed. Az is a new cross-platform PowerShell module that will replace AzureRM. You can install this module by running 'Install-Module Az' in an elevated PowerShell prompt."
    Write-Warning "If you see error 'AzureRM.Profile already loaded. Az and AzureRM modules cannot be imported in the same session', You would need to close the current session and start new one."
    exit
}

# Validate resourceId
if ($PSCmdlet.ParameterSetName -eq "One") {
    $matchResponse = $resourceId -match "/subscriptions/(.*?)/resourceGroups/"
    if(!$matchResponse)
    {
        Write-Warning "Invalid ResourceId format $resourceId."
        exit
    }
    $subscriptionId = $matches[1]
}

# Validating Set-Context success, suppress common warnings
Set-AzContext -Subscription $subscriptionId -ErrorVariable contextFailure -WarningAction SilentlyContinue | Out-Null
if ($contextFailure)
{
    Write-Warning "Unable to set subscription $subscriptionId in context. Please retry again."
    exit
}

### Script Scoped Functions and Variables
Function Build-Cert {
    param(
        [string]$Thumbprint,
        [string]$Sans,
        [string]$CNname,
        [string]$IssuerName,
        [string]$ValidityUpto
    )

    [PSCustomObject]@{
        Thumbprint = $Thumbprint
        Sans = $Sans
        CNname = $CNname
        IssuerName = $IssuerName
        ValidityUpto = $ValidityUpto
    }
}

Function Build-CertContainer {
    param(
        [string]$CertName,
        [System.Collections.Generic.List[object]]$Certificates
    )

    [PSCustomObject]@{
        CertName = $CertName
        Certificates = $Certificates
    }
}

Function Build-GatewayContainer {
    param(
        [string]$ResourceId,
        [System.Collections.Generic.List[object]]$CertificateList
    )

    [PSCustomObject]@{
        ResourceId = $ResourceId
        CertificateList = $CertificateList
    }
}

Function GetAllApplicationGateways {
    $Script:RunningGateways = @()
    $Private:StoppedGateways = @()

    if ($ResourceGroupName) {
        $Private:AllGateways = (Get-AzApplicationGateway -ResourceGroupName $ResourceGroupName | Select-Object Id, Sku, OperationalState, ProvisioningState)
    } else {
        $Private:AllGateways = (Get-AzApplicationGateway | Select-Object Id, Sku, OperationalState, ProvisioningState)
    }

    $Private:SupportedGateways = $Private:AllGateways | Where-Object { $_.Sku.Name -like "*v2" -or $_.Sku.Name -like "basic" }
    $Private:SupportedGateways | Foreach-Object {
        if ($_.OperationalState -eq "Running")
        {
            $Script:RunningGateways += $_
        } else {
            $Private:StoppedGateways += $_.Id
        }
    }
    if ($Script:RunningGateways.Count -eq 0) {
        Write-Warning "No supported Application Gateways in 'Running' state were found in subscription $subscriptionId. Exiting script."
        exit
    }

    if ($Private:StoppedGateways.Count -gt 0) {
        Write-Warning "There are $($Private:StoppedGateways.Count) Application Gateways not in 'Running' state and will be skipped."
        $Private:StoppedGatewaysFile = "StoppedAppGateways_$(Get-Date -Format 'yyyyMMdd').txt"
        $Private:StoppedGateways | Out-File -FilePath $Private:StoppedGatewaysFile
        Write-Warning "The list of stopped Application Gateways has been saved to $Private:StoppedGatewaysFile"
    }
}

### Private Scoped Functions
Function Private:ValidateUserContinuation()
{
    if ($env:CI -or -not $Host.UI.RawUI.KeyAvailable) {
        Write-Output "Non-interactive environment detected — Continuing."
        Continue
    }

    GetAllApplicationGateways

    $Count = $Script:RunningGateways.Count
    if ($Count -eq 1) {
        $Question = "There was $Count supported Application Gateway V2 found in the Subscription. Do you want to continue?"
    } else {
        $Question = "There were $Count supported Application Gateway V2s found. Do you want to continue?"
    }
    $Choices = [System.Management.Automation.Host.ChoiceDescription[]]@(
        (New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Continue the operation."),
        (New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Cancel the operation.")
    )

    $Decision = $Host.UI.PromptForChoice($null, $Question, $Choices, 1)

    if ($Decision -eq 0) {
        Write-Output "Continuing..."
    }
    else {
        Write-Output "Cancelled by user."
        exit
    }
}

Function Private:GetListenerCertificateMetadata {
    param(
        [Parameter(Mandatory=$true)]$Resource
    )
    $Path = "$($resource)/getListenerCertificateMetadata?api-version=2025-03-01"
    $Request = Invoke-AzRestMethod -Path $Path -Method POST
    $Content = $Request.Content | ConvertFrom-Json

    if ($Request.StatusCode -eq 400) {
        Write-Error "Invoke-AzRestMethod returned an 400. Details: $($Content.error.code)"
        Return
    }

    if ($Request.StatusCode -ne 202) {
        Write-Error "Invoke-AzRestMethod returned an unexpected status code. Expected 202, received $($Request.StatusCode). Details: $($Request.Content)"
        Return
    }

    Do {
        Write-Debug "Executing GetListenerCertificateMetadata for $resource"
        $Count++
        Write-Debug "Retrieval attempt $Count"

        Start-Sleep -Seconds 1
        $Response = Invoke-AzRestMethod -Path $Request.Headers.Location.PathAndQuery -Method GET
        $Status = $Response.StatusCode
    } While ($Status -ne "200")

    $listenerCertificateJson = ($Response.Content | ConvertFrom-Json).listenerCertificateDetails

    foreach ($name in $listenerCertificateJson.psobject.Properties.Name) {
        $listenerCertificateJson.$name = @($listenerCertificateJson.$name)
    }

    Return $listenerCertificateJson
}
Function Private:CleanCertificateMetadata {
    param(
        [Parameter(Mandatory=$true)]$ResourceId,
        [Parameter(Mandatory=$true)][PSCustomObject]$CertMetadata
    )
    $CertificateList = [System.Collections.Generic.List[object]]::new()

    foreach ($certGroup in $CertMetadata.psobject.Properties) {
        $FullChain = [System.Collections.Generic.List[object]]::new()
        $certName = $certGroup.Name

        foreach ($cert in $certGroup.Value) {
            $temp = Build-Cert -Thumbprint $cert.thumbprint -Sans $cert.saNs -CNname $cert.cnName -IssuerName $cert.issuerName -ValidityUpto $cert.validityUpto
            $FullChain.Add($temp)
        }

        $CertificateContainer = Build-CertContainer -CertName $certName -Certificates $FullChain
        $CertificateList.Add($CertificateContainer)
    }

    Return Build-GatewayContainer -ResourceId $ResourceId -CertificateList $CertificateList
}

Function Private:FormatCertificateOutput {
    param(
        [Parameter(Mandatory = $true)]$CertContainer,
        [string]$OutputFile = $null
    )

    $outputLines = @()
    $outputLines += "==================== Application Gateway Certificate Metadata ===================="
    $outputLines += "Resource: $($CertContainer.ResourceId)"
    $outputLines += "`tListener Certificate Metadata"

    foreach ($chain in $CertContainer.CertificateList) {
        $outputLines += "`t========================================"
        $outputLines += "`tCert Chain: $($chain.CertName)"
        # Loop through each certificate in this chain
        foreach ($cert in $chain.Certificates) {
            $line = "`t`tThumbprint: $($cert.Thumbprint)`n"
            $line += "`t`tCN: $($cert.CNname)`n"
            if ($cert.Sans -ne "") {
                $line += "`t`tSANs: $($cert.Sans -join ', ')`n"
            }
            $line += "`t`tIssuer: $($cert.IssuerName)`n"
            $line += "`t`tValid Until: $($cert.ValidityUpto)`n"
            $outputLines += $line
        }

    }

    # Output to console
    $outputLines | ForEach-Object { Write-Output $_ }

    # Optionally output to file
    if ($OutputFile) {
        $outputLines | Out-File -Append -FilePath $OutputFile -Encoding UTF8
    }
}

$sw = [Diagnostics.Stopwatch]::StartNew()

try{
    # Validate incase there are more than 1 AppGW found
    switch ($PSCmdlet.ParameterSetName) {
        'One' {
            $CertificateMetadata = GetListenerCertificateMetadata -Resource $ResourceId
            # Clean Listner Metadata
            $CleanedCertificates = CleanCertificateMetadata -ResourceId $ResourceId -CertMetadata $CertificateMetadata
            # Output and format
            FormatCertificateOutput -CertContainer $CleanedCertificates -OutputFile $OutputFile
        }
        'Many'{
            ValidateUserContinuation

            ForEach ($ResourceId in $Script:RunningGateways.Id) {
                # Get Listener Metadata by AppGW Resource ID
                $CertificateMetadata = GetListenerCertificateMetadata -Resource $ResourceId
                # Clean Listner Metadata from 1 AppGW
                $CleanedCertificates = CleanCertificateMetadata -ResourceId $ResourceId -CertMetadata $CertificateMetadata
                # Output and format
                FormatCertificateOutput -CertContainer $CleanedCertificates -OutputFile $OutputFile
            }
        }
    }
}
catch [Exception]
{
   Write-Output $_.Exception | Format-List -Force
}
finally
{
    $completionTime = "Application Gateway Certificate Metadata completed. TimeTaken : $($sw.Elapsed.TotalSeconds) seconds"
    Write-Output $completionTime
    if ($OutputFile) {
        Add-Content -Path $OutputFile -Value $completionTime
    }

    if ($TranscriptLog) {
        Stop-Transcript
    }
}