<#
.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"