Report Certificate and Secret Expiry for Azure Tenant

Searches one or more Azure Tenants for Certificates and Client Secrets. The script reports on all credentials discovered, with their expiry date and a 'hint' that identifies the secret.
If expired or soon-to-expire credentials are discovered, an event log is written - this can be used as a trigger to generate an alert
The Application specified in the credential set must have the following permissions:
Application.Read.All (mandatory) - to read the secret metadata attached to the application
User.Read.All (mandatory) - to report the owner name and contact details
Directory.Read.All (optional) - to report the tenant name
Version 2.0.20
Created on 2023-11-09
Modified on 2024-03-24
Created by Bill Powell
Downloads: 11

The Script Copy Script Copied to clipboard
<#
.SYNOPSIS
    Reports on Azure certificate and secret expiration dates.

.DESCRIPTION
    Reports on Azure certificate and secret expiration dates.
    If expired or soon-to-expire credentials are discovered, an event log is written - this can be used as a trigger to generate an alert

.PARAMETER TenantOrAppIdList
    The TenantOrAppIdList parameter specifies a comma-separated list of partial Tenant IDs and/or Application IDs. The script uses these ids to identify
    saved Azure parameters (generated by the 'AZ Store Azure Credentials' or the older 'AVD Store Azure Service Principal Credentials')
    The script requires the first 8 hex digits only of either the Application Id or the Tenant Id.

.PARAMETER WarningThresholdDays
    The WarningThresholdDays parameter is the time before certificate expiry at which the script will start generating event logs.

.PARAMETER WarningIntervalHours
    The WarningIntervalHours parameter interval in hours between alerts being raised. 
    It is suggested that this be set so that between 5 and 10 alerts might be generated before the secret expires

.PARAMETER MinimumFallbackSecretExtraDays
    The MinimumFallbackSecretExtraDays parameter is the minimum number of extra days that a Fallback secret should run beyond the expiry date of the Primary secret.
    This parameter is used to check that - where an alternative secret exists on an application, it needs to run for MinimumFallbackSecretExtraDays after the
    primary secret expires to be considered an acceptable fallback secret.
    Additional work would be required to support fallback secrets.

.PARAMETER EventLogName
    The EventLogName parameter is the Event Log Name (e.g. Application).
    It is recommended that in order to keep event log searching fast, a separate, small event log should be configured, on the machine where the script runs, e.g.
        New-EventLog -LogName "$EventLogName" -Source "$EventLogSource"
    It is recommended that the log should be set fairly small, and to wrap, e.g.
        Limit-EventLog -OverflowAction OverwriteAsNeeded -MaximumSize 1024KB -LogName "$EventLogName"

.PARAMETER EventLogSource
    The EventLogSource parameter is the source string that will be used to create events in the configured event log.

.PARAMETER EventLogBase
    The EventLogBase parameter is the start of Event Log Id Block (e.g. 1400).

.PARAMETER LogRetrievalWindow
    The LogRetrievalWindow parameter is how many days back we will search for logs.
    It should be greater than the WarningIntervalHours, and the event log should be large enough to hold this many days worth of logs.

.NOTES
    The Application specified in the credential set must have the following permissions:
    Application.Read.All (mandatory) - to read the secret metadata attached to the application
    User.Read.All (mandatory) - to report the owner name and contact details
    Directory.Read.All (optional) - to report the tenant name

    It is expected that the script will be configured to run daily, or more frequently. 
    To ensure that the trigger system has adequate time to process event logs, the script will create only one event log per run,
    so if multiple secret expiries are common (in environments where short secret lifetimes are mandated), 
    the script should be run multiple times per day

#>
[CmdletBinding()]
param (
    [parameter(mandatory=$false, HelpMessage='Tenant or Application Id - will accept list of 8-digit partial Ids to match stored config')][string]$TenantOrAppIdList,
    [parameter(mandatory=$false, HelpMessage='Threshold in days after which warnings will be raised')][int]$WarningThresholdDays = 60,
    [parameter(mandatory=$false, HelpMessage='Interval in hours between warnings')][int]$WarningIntervalHours = 3,
    [parameter(mandatory=$false, HelpMessage='Minimum number of extra days that a Fallback secret should run beyond the expiry date of the Primary secret')][int]$MinimumFallbackSecretExtraDays = 5,
    [parameter(mandatory=$false, HelpMessage='Event Log Name (e.g. Application)')][string]$EventLogName = 'ControlUpTest',
    [parameter(mandatory=$false, HelpMessage='Event Log Source (e.g. ControlUp)')][string]$EventLogSource = 'ControlUpApp',
    [parameter(mandatory=$false, HelpMessage='Start of Event Log Id Block (e.g. 1400)')][int]$EventLogBase = 1400,
    [parameter(mandatory=$false, HelpMessage='How many days back we will search for logs')][int]$LogRetrievalWindow = 30
)

#region immediate post-processing of parameters

[object[]]$script:TenantOrAppPartialList = $TenantOrAppIdList -split ','

#endregion

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

#region ControlUpScriptingStandards
$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { $VerbosePreference } else { 'SilentlyContinue' })
$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { $DebugPreference } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'erroraction' ] ) { $ErrorActionPreference } else { 'Continue' })
$ProgressPreference = 'SilentlyContinue'

[int]$outputWidth = 600
if( ( $PSWindow = (Get-Host).UI.RawUI ) -and ( $WideDimensions = $PSWindow.BufferSize ) )
{
    $WideDimensions.Width = $outputWidth
    $PSWindow.BufferSize = $WideDimensions
}
#endregion ControlUpScriptingStandards

#region Windows Event setup

# Test for existence of event log and abort if not found
# https://stackoverflow.com/questions/13851577/how-to-determine-if-an-eventlog-already-exists
$logFileExists = Get-EventLog -list | Where-Object {$_.logdisplayname -eq $EventLogName} 
if (! $logFileExists) {
    # this step produces a broad output message, so follows the set-up of the console output width
    $ErrorMessage = @"


========================================================================================================================================
This script requires an event log to be configured for triggering expiry alerts. A new log should be created on this machine, e.g.
    New-EventLog -LogName "$EventLogName" -Source "$EventLogSource"
It is recommended that the log should be set fairly small, and to wrap, e.g.
    Limit-EventLog -OverflowAction OverwriteAsNeeded -MaximumSize 1024KB -LogName "$EventLogName"
========================================================================================================================================


"@
    Write-Error $ErrorMessage
    exit 1
}

$EventLogId_CertificateExpiry = $EventLogBase + 0
$EventLogId_MissingPermission = $EventLogBase + 1

$script:CertExpiryEventWritten = $false

#endregion

$BareGuidRegex = "[a-fA-F\d]{8}-([a-fA-F\d]{4}-){3}[a-fA-F\d]{12}"

$CommonFolder = $env:ALLUSERSPROFILE

$CredentialFolder = Join-Path -Path $CommonFolder -ChildPath 'Controlup/ScriptSupport'

if (-not (Test-Path -LiteralPath $CredentialFolder -PathType Container)) {
    Write-Error "Failed: path does not exist or is not a folder $CredentialFolder"
    exit 1
}

Write-Debug "Executing as $env:USERNAME"

#region Token parsing

function ConvertFrom-EpochDate ([int]$EpochDate) {
    (Get-Date -Date "01-01-1970") + ([System.TimeSpan]::FromSeconds(($EpochDate)))
}

function Parse-JWTtoken {
 
    [cmdletbinding()]
    param([Parameter(Mandatory=$true)][string]$Token)
 
    #Validate as per https://tools.ietf.org/html/rfc7519
    #Access and ID tokens are fine, Refresh tokens will not work
    if (!$Token.Contains(".") -or !$Token.StartsWith("eyJ")) { Write-Error "Invalid token" -ErrorAction Stop }
 
    #Full Response
    $Response = New-Object PSObject | Select-Object -Property Header,Payload
 
    #Header
    $tokenheader = $Token.Split(".")[0].Replace('-', '+').Replace('_', '/')
    #Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0
    while ($tokenheader.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $tokenheader += "=" }
    Write-Verbose "Base64 encoded (padded) header:"
    Write-Verbose $tokenheader
    #Convert from Base64 encoded string to PSObject all at once
    Write-Verbose "Decoded header:"
    $Response.Header = [System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | ConvertFrom-Json
 
    #Payload
    $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/')
    #Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0
    while ($tokenPayload.Length % 4) { Write-Verbose "Invalid length for a Base-64 char array or string, adding ="; $tokenPayload += "=" }
    Write-Verbose "Base64 encoded (padded) payoad:"
    Write-Verbose $tokenPayload
    #Convert to Byte array
    $tokenByteArray = [System.Convert]::FromBase64String($tokenPayload)
    #Convert to string array
    $tokenArray = [System.Text.Encoding]::ASCII.GetString($tokenByteArray)
    Write-Verbose "Decoded array in JSON format:"
    Write-Verbose $tokenArray
    #Convert from JSON to PSObject
    $Response.Payload = $tokenArray | ConvertFrom-Json
    Write-Verbose "Decoded Payload:"
    
    return $Response
}

#endregion

#region Azure Authentication and Authorisation

#
# see https://communary.net/2015/04/05/encodedecode-base64url/
function Invoke-Base64UrlEncode {
    <#
        .SYNOPSIS
        .DESCRIPTION
        .NOTES
            http://blog.securevideo.com/2013/06/04/implementing-json-web-tokens-in-net-with-a-base-64-url-encoded-key/
            Author: ?yvind Kallstad
            Date: 23.03.2015
            Version: 1.0
    #>
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [byte[]] $Argument
    )

    $output = [System.Convert]::ToBase64String($Argument)
    $output = $output.Replace('=', '')
    $output = $output.Replace('+', '-')
    $output = $output.Replace('/', '_')
    
    Write-Output $output
}

function New-JSONWebToken {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
            [string[]] $StringList,
        [Parameter(Mandatory)]
            $Certificate
    )
    $EncodedStringList = @()
    $StringList | ForEach-Object {
        $ClearTextString = $_
        # Convert supplied strings to base64
        $ClearTextBytes = [System.Text.Encoding]::UTF8.GetBytes($ClearTextString)
        $Base64EncodedString = Invoke-Base64UrlEncode $ClearTextBytes
        $EncodedStringList += $Base64EncodedString
    }

    # Join header and Payload with "." to create a valid (unsigned) JWT
    $StringToSign = $EncodedStringList -join '.'

    # Get the private key object of your certificate
    $PrivateKey = $Certificate.PrivateKey

    # Define RSA signature and hashing algorithm
    $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1
    $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256

    # Create a signature of the JWT
    $Signature = Invoke-Base64UrlEncode ($PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($StringToSign),$HashAlgorithm,$RSAPadding))

    # Join the signature to the JWT with "."
    $JWT = $StringToSign + "." + $Signature
    $JWT
}

function Get-AzRESTToken {
    [CmdletBinding()]
    param (
        [Parameter(mandatory=$true,  ParameterSetName = 'Certificate')]
            $Certificate,
        [Parameter(mandatory=$true,  ParameterSetName = 'ClientSecret')]
            $ClientSecret,
        [Parameter(mandatory=$true)]
            $ApplicationId,
        [Parameter(mandatory=$true)]
            $TenantId,
        [Parameter(mandatory=$false)]
            $Scope = "https://vault.azure.net/.default"
    )
    #
    # we support authentication by Certificate and also by Client Secret
    switch ($PSCmdlet.ParameterSetName) {
        'Certificate' {
                $epoch = [datetime]::Parse("1970-01-01T00:00:00Z")
                $now = Get-Date

                $nowSeconds = [math]::Floor(($now - $epoch).TotalSeconds)
                $after10minsSeconds = $nowSeconds + (10 * 60)

                $CertificateBase64Hash = [System.Convert]::ToBase64String($Certificate.GetCertHash())
                $EndPoint = "https://login.microsoftonline.com/$TenantId/oauth2/token"
                $randomGuid = ([guid]::NewGuid()).Guid
                #
                # build the payload
                $JWTHeader = @"
{
    "alg": "RS256",
    "typ": "JWT",
    "x5t": "$($CertificateBase64Hash -replace '\+','-' -replace '/','_' -replace '=')"
}
"@

                $JWTHeader = $JWTHeader | ConvertFrom-Json | ConvertTo-Json -Compress

                $JWTClaims = @"
{
    "aud": "$EndPoint",
    "exp": $after10minsSeconds,
    "iss": "$ApplicationId",
    "jti": "$randomGuid",
    "nbf": $nowSeconds,
    "sub": "$ApplicationId"
}
"@

                $JWTClaims = $JWTClaims | ConvertFrom-Json | ConvertTo-Json -Compress

                $JWT = New-JSONWebToken -StringList @($JWTHeader,$JWTClaims) -Certificate $Certificate

                # Create a hash with body parameters
                $Body = @{
                    client_id = $ApplicationId
                    client_assertion = $JWT
                    client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                    scope = $Scope
                    grant_type = "client_credentials"

                }

                # Use the self-generated JWT as Authorization
                $Header = @{
                    Authorization = "Bearer $JWT"
                }

            }
        'ClientSecret' {
                # Create a hash with body parameters
                $Body = @{
                    client_id = $ApplicationId
                    client_secret = $ClientSecret
                    scope = $Scope
                    grant_type = "client_credentials"

                }

                # Use the client secret as Authorization
                $Header = @{
                    Authorization = "Bearer $ClientSecret"
                }

            }
        default {}
    }
    #
    # common section for both certificate and client secret paths
    $Url = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

    # Splat the parameters for Invoke-Restmethod for cleaner code
    $PostSplat = @{
        ContentType = 'application/x-www-form-urlencoded'
        Method = 'POST'
        Body = $Body
        Uri = $Url
        Headers = $Header
    }

    $Request = Invoke-RestMethod @PostSplat

    $Request
}

#endregion

#region MS Graph Functions

$script:GraphScope          = 'https://graph.microsoft.com/.default'

function Get-MSGraphGeneral {
    [CmdletBinding()]
#    [OutputType([string])]
    param (
        [parameter (mandatory=$true)] [string] $BaseURL,
        [parameter (mandatory=$true)] [string] $Tail,
        [parameter (mandatory=$true)] $Token
    )

    if ($Token.GetType().Name -eq 'AuthenticationResult') {
        #
        # MSAL token
        $AccessToken = $Token.AccessToken
    }
    else {
        #
        # client secret-derived token from KeyVault REST API
        $AccessToken = $Token.access_token
    }
    $headers = @{}
#    $headers["Content-Type"] = 'application/json'
    $headers["Authorization"] = "Bearer $($AccessToken)"
#    $headers["Accept"] = 'application/json; version=4'

    $Url = $BaseURL + $Tail
#    $Url = $Url + "?api-version=7.3"

    # Splat the parameters for Invoke-Restmethod for cleaner code
    $GetSplat = @{
#        ContentType = 'application/json'
        Method = 'GET'
        # Create string by joining bodylist with '&'
      #  Body = $ExternalReference #| ConvertTo-Json
        Uri = $Url
        Headers = $headers
    }

    # Request the data
    $Response = Invoke-RestMethod @GetSplat
    $Response
}

#endregion

#region Miscellaneous setup

$CredentialFields = 'CredentialType,DisplayName,ExpiryDateTime,DaysLeft,ApplicationDisplayName,ApplicationId,CertThumbprint,SecretHint,SecretId,OwnerList,OwnerMailList,OwnerUPNList,StartDateTime,Detail' -split ','
function New-CredentialRec {
    [CmdletBinding()]
    param (
    )
    New-Object PSObject | Select-Object -Property $CredentialFields
}

$script:WidToPermission = @{}
$script:WidToPermission['0997a1d0-0d1d-4acb-b408-d5ca73121e90'] = 'default service principal permissions?'
$script:WidToPermission['88d8e3e3-8f55-4a1e-953a-9b9898b8876b'] = 'Directory Readers'
$script:WidToPermission['f2ef992c-3afb-46b9-b7cf-a126ee74c451'] = 'Global Reader'

$script:WidsUsed = @{}

#endregion

#region Event Log Management

$script:MinutesToWaitBetweenCertificateEvents = -5    # minutes
$script:Now = Get-Date

function Retrieve-TenantApplicationLogs {
    [CmdletBinding()]
    param (
        [parameter(mandatory=$true, HelpMessage='Tenant GUID')][string]$TenantID,
        [parameter(mandatory=$false, HelpMessage='Application GUID')][string]$ApplicationID
    )
    #
    # retrieve logs for the past N days
    $StartTime = $script:Now.AddDays(- $LogRetrievalWindow)
    $FilterHashtable = @{
        Logname      = $EventLogName
        ID           = @($EventLogId_CertificateExpiry, $EventLogId_MissingPermission)
        StartTime    = $StartTime
    }
    $script:AllEvents = Get-WinEvent -FilterHashtable $FilterHashtable -ErrorAction SilentlyContinue | 
      Sort-Object -Property TimeCreated -Descending |
      ForEach-Object {
        $LoggedEvent = $_
        #
        # filter out logs that aren't for this tenant or application
        $Pass = $false
        $LoggedEvent.Message -split "[`r`n]+" | ForEach-Object {
            $Line = $_
            if ($Line -like "*$TenantID*") {
                $Pass = $true
            }
            if ((-not [string]::IsNullOrWhiteSpace($ApplicationID)) -and ($Line -like "*$ApplicationID*")) {
                $Pass = $true
            }
        }
        if ($Event.Id -eq $EventLogId_CertificateExpiry) {
            if ($Event.TimeCreated -ge $script:Now.AddMinutes($script:MinutesToWaitBetweenCertificateEvents)) {
                $Pass = $true    # include all certificate expiry logs newer than 5 mins
            }
        }
        if ($Pass) {
            $LoggedEvent
        }
    }
    # $script:AllEvents | Format-Table
}

function Get-ApplicationIdBytes {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param (
        [parameter(mandatory=$true, HelpMessage='Application GUID')][string]$ApplicationID
    )
    [byte[]] -split ($ApplicationID -replace "-.*",'' -replace '(..)','0x$& ')
}

function Write-MissingPermissionEvent {
    [CmdletBinding()]
    param (
        [parameter(mandatory=$true, HelpMessage='Tenant GUID')][string]$TenantID,
        [parameter(mandatory=$true, HelpMessage='Application GUID')][string]$ApplicationID,
        [parameter(mandatory=$true, HelpMessage='The name of the missing permission')][string]$Permission,
        [parameter(mandatory=$true, HelpMessage='A message explaining how the missing permission is impacting function')][string]$Context
    )
    [object[]]$RelevantEvents = $script:AllEvents | 
      Where-Object {$_.Id -eq $EventLogId_MissingPermission}
    if ($RelevantEvents.Count -eq 0) {
        $Message = @"
Application Id = $($ApplicationId)
Tenant Id = $($TenantId)
Permission Required = $($Permission)
Reason = $($Context)
"@
        try {
            Write-EventLog -LogName $EventLogName `
                            -Source $EventLogSource `
                            -EventID $EventLogId_MissingPermission `
                            -EntryType Warning `
                            -Message $Message `
                            -RawData (Get-ApplicationIdBytes $ApplicationID)
        }
        catch {
            $exception = $_
            #Write-Host -ForegroundColor Red "exception $($exception.Exception.Message)"
        }
    }
}

#endregion

#region The core credential expiry bits

function Report-CredentialExpiry {
    [CmdletBinding()]
    param (
        [parameter(mandatory=$true, HelpMessage='Tenant GUID')][string]$TenantID,
        [parameter(mandatory=$false, HelpMessage='Tenant Name')][string]$TenantName,
        [parameter(mandatory=$true, HelpMessage='Application GUID')][string]$ApplicationID,
        [parameter(mandatory=$false, HelpMessage='Application Name')][string]$ApplicationName,
        [parameter(mandatory=$true, HelpMessage='Application Secret')][string]$ApplicationSecret
    )
    $Response = [pscustomobject]@{
        TenantName = $null
        TenantId = $TenantID
        DomainName = $null
        ApplicationName = $ApplicationName
        ApplicationId = $ApplicationID
        CredentialList = @()
        Warnings = @()
        Failures = @()
        ExpiryWarnings = @()
        WindowsEventsWritten = @()
    }
    #
    # need to get a token
    try {
        $tok = Get-AzRESTToken -ApplicationId $ApplicationID `
                               -TenantId $TenantID `
                               -ClientSecret $ApplicationSecret `
                               -Scope $script:GraphScope
    }
    catch {
        $exception = $_
        $Response.Failures += "Failed to obtain token - check credentials are valid: $($exception.Exception.Message)"
        return $Response
    }
    #
    # inspect the token and its permissions
    $DecodedToken = Parse-JWTtoken $tok.access_token
    $DecodedToken.Payload.wids | ForEach-Object {
        $PermissionGuid = $_
        $script:WidsUsed[$PermissionGuid] = $true
        $PermissionName = $script:WidToPermission[$PermissionGuid]
        Write-Debug "Token has permission $PermissionName"
    }

    #
    # get the event logs relating to this tenant and application
    Retrieve-TenantApplicationLogs -TenantID $TenantID `
                                   -ApplicationID $ApplicationID

    #
    # find out about this tenant
    try {
        $tenant = Get-MSGraphGeneral -BaseURL 'https://graph.microsoft.com/beta' -Tail "/tenantRelationships/findTenantInformationByTenantId(tenantId='$TenantID')" -Token $tok
        $Response.TenantName = $tenant.displayName
        $Response.DomainName = $tenant.defaultDomainName
        $TenantName = $tenant.displayName
    }
    catch {
        $exception = $_
        if ($exception.Exception.Response.StatusCode.value__ -eq 403) {
            $Response.Warnings += "Unable to determine tenant name from tenantid: $($exception.Exception.Message). This operation requires Directory.Read.All permissions or higher."
            Write-MissingPermissionEvent -TenantID $TenantID `
                                         -ApplicationID $ApplicationID `
                                         -Permission 'Directory.Read.All' `
                                         -Context 'Unable to retrieve tenant name for easy identification of tenant in reports'
        }
        else {
            $Response.Warnings += "Unable to determine tenant name from tenantid: $($exception.Exception.Message)."
        }
    }

    #
    # get a list of permission ids
    $PermissionById = @{}
    try {
        $SpecialServicePrincipal = Get-MSGraphGeneral -BaseURL 'https://graph.microsoft.com/v1.0' -Tail "/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')?`$select=id,appId,displayName,appRoles,oauth2PermissionScopes,resourceSpecificApplicationPermissions" -Token $tok
        @('appRoles','oauth2PermissionScopes','resourceSpecificApplicationPermissions') | ForEach-Object {
            $FieldName = $_
            $SpecialServicePrincipal.$FieldName | ForEach-Object {
                $PermissionRec = $_
                $PermissionById[$PermissionRec.id] = $PermissionRec
            }
        }
    }
    catch {
        $exception = $_
        if ($exception.Exception.Response.StatusCode.value__ -eq 403) {
            $Response.Warnings += "Unable to retrieve Service Principal from tenantid: $($exception.Exception.Message). This operation requires Application.Read.All permissions or higher."
            Write-MissingPermissionEvent -TenantID $TenantID `
                                         -ApplicationID $ApplicationID `
                                         -Permission 'Application.Read.All' `
                                         -Context 'Unable to retrieve Service Principal to determine missing permissions'
        }
        else {
            $Response.Warnings += "Unable to determine tenant name from tenantid: $($exception.Exception.Message)."
        }
    }

    #
    # find out about this application - alternate
    try {
        $ThisApplication = Get-MSGraphGeneral -BaseURL 'https://graph.microsoft.com/v1.0' -Tail "/applications(appId='{$ApplicationID}')" -Token $tok
        $Response.ApplicationName = $ThisApplication.displayName
        $ThisApplication.requiredResourceAccess | ForEach-Object {
            $Item = $_
            $Item.resourceAccess | ForEach-Object {
                $PermissionId = $_.id
                $ResourceType = $_.type
                $PermissionRec = $PermissionById[$PermissionId]
                if ($null -ne $PermissionRec) {
                    Write-Debug "Application $($ThisApplication.displayName) has $ResourceType $($PermissionRec.value)"
                }
                else {
                    Write-Debug "Application $($ThisApplication.displayName) has $ResourceType $($PermissionId)"
                }
            }
        }
    }
    catch {
        $exception = $_
        $Response.Warnings += "Application $($ApplicationID) does not have rights to read its own application data using /applications(appId='{<ApplicationID>}'): $($exception.Exception.Message). This operation requires Application.Read.All permissions or higher."
    }

    #
    # find out about all applications
    try {
        $Applications = Get-MSGraphGeneral -BaseURL 'https://graph.microsoft.com/v1.0' -Tail "/applications" -Token $tok
    }
    catch {
        $exception = $_
        $Response.Failures += "Application $($ApplicationID) does not have rights to read application data. This operation requires Application.Read.All permissions or higher."
        return $Response
    }

    $Response.CredentialList = $Applications.value | ForEach-Object {
        $Application = $_
        $OwnerList = @()
        $OwnerMailList = @()
        $OwnerUPNList = @()
        #
        # find the application owner(s)
        try {
            $ApplicationOwnerList = Get-MSGraphGeneral -BaseURL 'https://graph.microsoft.com/v1.0' -Tail "/applications/$($Application.Id)/owners?`$select=id,businessPhones,displayName,givenName,jobTitle,mail,mobilePhone,officeLocation,preferredLanguage,surname,userPrincipalName,otherMails" -Token $tok
            $ApplicationOwnerList.value | ForEach-Object {
                $Owner = $_
                $OwnerList += $Owner.displayName
                if (-not [string]::IsNullOrEmpty($Owner.mail)) {
                    $OwnerMailList += $Owner.mail
                }
                $Owner.otherMails | ForEach-Object {
                    $OwnerMailList += $_
                }
                if (-not [string]::IsNullOrEmpty($Owner.userPrincipalName)) {
                    $OwnerUPNList += $Owner.userPrincipalName
                }
            }
            if ($OwnerList.Count -eq 0) {
                $OwnerList += "No owner assigned"
            }
        }
        catch {
            $exception = $_
            switch ($exception.Exception.Response.StatusCode.value__) {
            404 {
                    #
                    # no owner found
                    $OwnerList += "No owner assigned"
                }
            default {
                    $Response.Warnings += "Unknown error when requesting owner for $($Application.name)."
                }
            }
        }
        if ($Application.appId -eq $ApplicationID) {
            Write-Debug "Application $($Application.displayName) has rights to read application data"
        }
        if ($Application.displayName -eq 'Postman Joel') {
           # $Application | ConvertTo-Json -Depth 6
        }
        $Application.keyCredentials | ForEach-Object {
            $KeyCredential = $_
            $ExpiryRecord = New-CredentialRec
            $ExpiryRecord.CredentialType = $KeyCredential.type
            $ExpiryRecord.DisplayName = $KeyCredential.displayName
            $ExpiryRecord.ApplicationDisplayName = $Application.displayName
            $ExpiryRecord.ApplicationId = $Application.appId
            $ExpiryRecord.CertThumbprint = $KeyCredential.customKeyIdentifier
            $ExpiryRecord.SecretHint = $null
            $ExpiryRecord.StartDateTime = [datetime]::Parse($KeyCredential.startDateTime)
            $ExpiryRecord.ExpiryDateTime = [datetime]::Parse($KeyCredential.endDateTime)
            $ExpiryRecord.DaysLeft = $([math]::Round(($ExpiryRecord.ExpiryDateTime - $script:Now).TotalDays,1))
            $ExpiryRecord.SecretId = $KeyCredential.keyId
            $ExpiryRecord.Detail = $KeyCredential.usage
            $ExpiryRecord.OwnerList = $OwnerList -join ','
            $ExpiryRecord.OwnerMailList = $OwnerMailList -join ','
            $ExpiryRecord.OwnerUPNList = $OwnerUPNList -join ','
            $ExpiryRecord
        }
        $Application.passwordCredentials | ForEach-Object {
            $PassCredential = $_
            $ExpiryRecord = New-CredentialRec
            $ExpiryRecord.CredentialType = 'ClientSecret'
            $ExpiryRecord.DisplayName = $PassCredential.displayName
            $ExpiryRecord.ApplicationDisplayName = $Application.displayName
            $ExpiryRecord.ApplicationId = $Application.appId
            $ExpiryRecord.CertThumbprint = $null
            $ExpiryRecord.SecretHint = $PassCredential.hint
            $ExpiryRecord.StartDateTime = [datetime]::Parse($PassCredential.startDateTime)
            $ExpiryRecord.ExpiryDateTime = [datetime]::Parse($PassCredential.endDateTime)
            $ExpiryRecord.DaysLeft = $([math]::Round(($ExpiryRecord.ExpiryDateTime - $script:Now).TotalDays,1))
            $ExpiryRecord.SecretId = $PassCredential.keyId
            $ExpiryRecord.Detail = $null
            $ExpiryRecord.OwnerList = $OwnerList -join ','
            $ExpiryRecord.OwnerMailList = $OwnerMailList -join ','
            $ExpiryRecord.OwnerUPNList = $OwnerUPNList -join ','
            $ExpiryRecord
        }
    } | Sort-Object -Property ExpiryDateTime

    #
    # now warn of expired / expiring certificates/secrets
    $ExpiryWarningDate = $script:Now.AddDays($WarningThresholdDays)
    $Response.CredentialList | Where-Object {$_.ExpiryDateTime -lt $ExpiryWarningDate} | ForEach-Object {
        $ExpiryRecord = $_
        if ($ExpiryRecord.CredentialType -eq 'ClientSecret') {
            $ThumbPrintOrHint = $ExpiryRecord.SecretHint
        }
        else {
            $ThumbPrintOrHint = $ExpiryRecord.CertThumbprint
        }
        #
        # is there a fallback secret?
        $SafeFallbackExpiryDate = $ExpiryRecord.ExpiryDateTime.AddDays($MinimumFallbackSecretExtraDays)
        $FallbackSecrets = @()
        $Response.CredentialList | 
            Where-Object {$_.ApplicationId -eq $ExpiryRecord.ApplicationId} |    # the secret is for the same application
            Where-Object {$_.ExpiryDateTime -ge $SafeFallbackExpiryDate} |       # the secret is valid for a decent time after the older secret expires
            Where-Object {$_.StartDateTime -lt $ExpiryRecord.ExpiryDateTime} |   # there is some overlap 
            Where-Object {$_.CredentialType -eq $ExpiryRecord.CredentialType} |  # must be same type of secret - not easy to replace a client secret with a certificate 
            Where-Object {$_.ExpiryDateTime -ge $script:Now} |                   # the secret hasn't already expired!
            Sort-Object -Property ExpiryDateTime -Descending |                   # most-suitable is first
            ForEach-Object {$FallbackSecrets += $_}
        if ($FallbackSecrets.Count -gt 0) {
            $FallbackSecretType = $FallbackSecrets[0].CredentialType
            $FallbackSecretName = $FallbackSecrets[0].DisplayName
        }
        else {
            $FallbackSecretType = 'N/A'
            $FallbackSecretName = 'N/A'
        }
        $Message = @"
Azure Application Secret Expiry Warning:
Tenant Id = $($TenantId)
Tenant Name = $($TenantName)
Application Id = $($ExpiryRecord.ApplicationId)
Application Name = $($ExpiryRecord.ApplicationDisplayName)
Secret Type = $($ExpiryRecord.CredentialType)
Secret Id = $($ExpiryRecord.SecretId)
Secret Name = $($ExpiryRecord.DisplayName)
ThumbPrint or Hint = $($ThumbPrintOrHint)
Secret Expiry Date = $($ExpiryRecord.ExpiryDateTime.ToString("O"))
Secret Lifetime (days) = $([math]::Round(($ExpiryRecord.ExpiryDateTime - $script:Now).TotalDays,1))
Fallback Secrets Available = $($FallbackSecrets.Count)
Fallback Secret Type = $($FallbackSecretType)
Fallback Secret Name = $($FallbackSecretName)
Owner List = $($ExpiryRecord.OwnerList)
Send Mail To Emails = '$($ExpiryRecord.OwnerMailList)'
Create Action For UPNs = '$($ExpiryRecord.OwnerUPNList)'
"@
        $Response.ExpiryWarnings += $Message
        #
        # now work out whether we can log the message
        # refresh the event logs relating to this tenant and application
        if ($ThumbPrintOrHint -eq 'Ws6') {
            $ThumbPrintOrHint | Out-Null
        }
        Start-Sleep -Seconds 10
        Retrieve-TenantApplicationLogs -TenantID $TenantID  # get event logs for all applications
      #  Write-Host -ForegroundColor Magenta "CertEvent: retrieved $($script:AllEvents.Count) events"
        #
        # work out whether the key information matches (secret id)
        [object[]]$CertExpiryEvents = $script:AllEvents | Where-Object {$_.Id -eq $EventLogId_CertificateExpiry}
      #  Write-Host -ForegroundColor Magenta "CertEvent: retrieved $($CertExpiryEvents.Count) certificate expiry events"
        [object[]]$RelevantEvents = $CertExpiryEvents | ForEach-Object {
            $LoggedEvent = $_
            $SecretId = $null
            $LoggedEvent.Message -split "[`r`n]+" | ForEach-Object {
                $Line = $_
                if ($Line -match "^Secret Id = (?<secretid>$BareGuidRegex)$") {
                    $SecretId = $Matches.secretid
                }
            }
          #  Write-Host -ForegroundColor Magenta "CertEvent: Time created $($LoggedEvent.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))"
            if ($LoggedEvent.TimeCreated -gt $script:Now.AddHours(- $WarningIntervalHours)) {
                # recent event
                if ($SecretId -eq $ExpiryRecord.SecretId) {
                    # that matches the secret we're interested in
                  #  Write-Host -ForegroundColor Magenta "CertEvent: Match on secret id $SecretId"
                    $LoggedEvent
                }
                else {
                  #  Write-Host -ForegroundColor Magenta "CertEvent: event secret id $SecretId does not match required secret id $($ExpiryRecord.SecretId)"
                }
            }
            elseif ($LoggedEvent.TimeCreated -ge $script:Now.AddMinutes($script:MinutesToWaitBetweenCertificateEvents)) {
                # very recent event
              #  Write-Host -ForegroundColor Magenta "CertEvent: Match on cert expiry event in last 5 minutes"
                $LoggedEvent
            }
            else {
              #  Write-Host -ForegroundColor Magenta "CertEvent: ignored event"
            }
        }
        if ($RelevantEvents.Count -eq 0) {
            #
            # write a new event
            try {
              #  Write-Host -ForegroundColor Magenta "CertEvent: Writing cert expiry event for $($ExpiryRecord.SecretId)"
                if (-not $script:CertExpiryEventWritten) {
                    #
                    # as advised by Wouter Kursten:
                    # in order to leave a sufficient gap between cert expiry events, we will only write one per iteration of the script.
                    Write-EventLog -LogName $EventLogName `
                                   -Source $EventLogSource `
                                   -EventID $EventLogId_CertificateExpiry `
                                   -EntryType Warning `
                                   -Message $Message `
                                   -RawData (Get-ApplicationIdBytes $ExpiryRecord.ApplicationId)
                    $script:CertExpiryEventWritten = $true
                    $Response.WindowsEventsWritten += $Message
                }
            }
            catch {
                $exception = $_
                $Response.Failures += "exception $($exception.Exception.Message)"
            }
        }
        else {
          #  Write-Host -ForegroundColor Magenta "CertEvent: defer writing cert expiry event"
        }
    }
    $Response
}

#endregion

#region For "Final form" SBA development work out what tenants, applications and secrets we have to work with 

$script:ApplicationIdsTried = @{}

$FolderItem = Get-Item -LiteralPath $CredentialFolder
$FolderItem.GetFiles() | ForEach-Object {
    $FileItem = $_
    Write-Debug "processing credential file $($FileItem.Name)"
    $Owner = $null
    if ($FileItem.Name -match "^(?<owner>.*)_(?<tenant>.*)_(?<provider>.*)_Cred.xml$"){
        $Owner = $Matches.owner
        $TenantId = $Matches.Tenant
        $Provider = $Matches.provider
    }
    elseif ($FileItem.Name -match "^(?<owner>.*)_AZ_Cred.xml$"){
        $Owner = $Matches.owner
        $TenantId = $null
        $Provider = 'Azure'
    }
    if ($Owner -eq $env:USERNAME) {
        $Credential = Import-Clixml -LiteralPath $FileItem.FullName
        if ($TenantId -eq $null) {
            $TenantId = $Credential.tenantID
        }
        $ApplicationId = $Credential.spCreds.UserName
        if ($script:TenantOrAppPartialList.Count -gt 0) {
            $Proceed = $false
            $script:TenantOrAppPartialList | ForEach-Object {
                $TenantOrAppPartial = $_
                if ($TenantId.StartsWith($TenantOrAppPartial)) {
                    $Proceed = $true
                }
                if ($ApplicationId.StartsWith($TenantOrAppPartial)) {
                    $Proceed = $true
                }
            }
        }
        else {
            $Proceed = $true
        }
        if ($Proceed) {
            if ($script:ApplicationIdsTried[$ApplicationId] -ne $true) {
                $script:ApplicationIdsTried[$ApplicationId] = $true
                $BSTR2 = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential.spCreds.Password)
                $ApplicationSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR2)
                try {
                    $Response = Report-CredentialExpiry -TenantID $TenantId `
                                                        -ApplicationID $ApplicationId `
                                                        -ApplicationSecret $ApplicationSecret
                    $Response | Out-Null
                    Write-Output ""
                    Write-Output ("=" * 130)
                    Write-Output "Application ID $($Response.ApplicationId), Tenant ID $($Response.TenantId), Name = $($Response.TenantName)"
                    Write-Output ("=" * 130)
                    if ($Response.CredentialList.Count -gt 0) {
                        $Response.CredentialList | Format-Table -Property CredentialType,DisplayName,@{Expression={$_.ExpiryDateTime.ToString('yyyy-MM-dd')};Label="ExpiryDate";width=11},DaysLeft,ApplicationDisplayName,ApplicationId,CertThumbprint,@{Expression={$_.SecretHint};Label="Hint";width=5},OwnerList # ,OwnerMailList,OwnerUPNList,SecretId,@{Expression={$_.StartDateTime.ToString('yyyy-MM-dd HH:mm:ss')};Label="StartDateTime";width=19},Detail
                    }
                    else {
                        $Response | Out-Null
                        Write-Output "Unable to retrieve Application credential expiry information:"
                        $Response.Failures | ForEach-Object {
                            Write-Output "Failure: $_"
                        }
                        $Response.Warnings | ForEach-Object {
                            Write-Output "Warning: $_"
                        }
                    }
                }
                catch {
                    $exception = $_
                    Write-Output "Tenant $TenantId AppId $ApplicationId query failed: $($exception.Exception.Message)"
                }
                $Credential | Out-Null
            }
        }
    }
}

#endregion

#
# now write out errors / advice
Write-Output "Done"