Back to script library
Entra / Microsoft 365 · Applications

Report app audit events

Uses the AuditLog Query Graph API to fetch audit events for app creation and updates and report them to administrators.

Connect & set up

Run these once per session. All scopes are read-only unless the script makes changes.

Connect-MgGraph -NoWelcome -Scopes User.Read.All

Run it

The main script. Copy it, or download the .ps1 and run it from your console.

param(
[string] $AppId = "$App.AppId",
[int] $LookbackDays = 90
)
If ([Environment]::UserInteractive) {
# We're running interactively...
Write-Host "Running interactively..."
Connect-MgGraph -NoWelcome -Scopes User.Read.All
# Define message sender for email sent at the end of the script
$MsgFrom = (Get-MgContext).Account
Write-Host "Email will be sent from $MsgFrom"
} Else {
# We're not, so likely in Azure Automation
Write-Output "Script executing as a Azure Automation runbook..."
Connect-MgGraph -Identity -NoWelcome
# Define the message sender - change this value to the email address of the account that will send the message
$MsgFrom = 'Customer.Services@office365itpros.com'
Write-Output "Email will be sent from $MsgFrom"
}
# Check for required permissions
[array]$RequiredPermissions = @('AuditLogsQuery.Read.All', 'Mail.Send', 'Application.Read.All', 'DelegatedPermissionGrant.Read.All')
[array]$CurrentPermissions = Get-MgContext | Select-Object -ExpandProperty Scopes
$MissingPermissions = $RequiredPermissions | Where-Object { $_ -notin $CurrentPermissions }
If ($MissingPermissions.Count -gt 0) {
Write-Output ("The following required permissions are missing: {0}" -f ($MissingPermissions -join ', '))
Write-Output "Please ensure that the account has the required permissions and try again."
Break
} Else {
Write-Output "All required permissions are present."
}
$AuditJobName = ("Audit job created at {0}" -f (Get-Date -format 'dd-MMM-yyyy HH:mm'))
$AuditQueryStart = ((Get-Date).AddDays(-$LookbackDays).toString('yyyy-MM-ddTHH:mm:ss'))
$AuditQueryEnd = ((Get-Date).toString('yyyy-MM-ddTHH:mm:ss'))
[array]$AuditOperationFilters = "Add app role assignment to service principal.", "Add application.", "Add delegated permission grant.", "Update application - Certificates and secrets management", "Consent to application"
$AuditQueryParameters = @{}
$AuditQueryParameters.Add("@odata.type","#microsoft.graph.security.auditLogQuery")
$AuditQueryParameters.Add("displayName", $AuditJobName)
$AuditQueryParameters.Add("OperationFilters", $AuditOperationFilters)
$AuditQueryParameters.Add("filterStartDateTime", $AuditQueryStart)
$AuditQueryParameters.Add("filterEndDateTime", $AuditQueryEnd)
$Uri = "https://graph.microsoft.com/beta/security/auditLog/queries"
$AuditJob = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $AuditQueryParameters
If ($null -eq $AuditJob) {
Write-Output "Failed to create audit query job. Please check the code and permissions and try again."
Exit
} Else {
Write-Output ("Audit query job {0} created with ID {1}" -f $AuditJobName, $AuditJob.id)
Write-Output ("Query start time: {0}, end time: {1}" -f $AuditQueryStart, $AuditQueryEnd)
Write-Output ("Operations to search for: {0}" -f ($AuditOperationFilters -join ', '))
}
# Check the audit query status every 20 seconds until it completes
[int]$i = 1
[int]$SleepSeconds = 20
$SearchFinished = $false; [int]$SecondsElapsed = 20
# Write-Host "Checking audit query status..."
# Initial wait to let the audit job spin up
Start-Sleep -Seconds 30
$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}" -f $AuditJob.id)
$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method Get
While ($SearchFinished -eq $false) {
$i++
#Write-Host ("Waiting for audit search to complete. Check {0} after {1} seconds. Current state {2}" -f $i, $SecondsElapsed, $AuditQueryStatus.status)
If ($AuditQueryStatus.status -eq 'succeeded') {
$SearchFinished = $true
} Else {
Start-Sleep -Seconds $SleepSeconds
$SecondsElapsed = $SecondsElapsed + $SleepSeconds
$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method Get
}
}
# Fetch audit records found by the search
$AuditRecords = [System.Collections.Generic.List[string]]::new()
$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}/records?`$top=999" -f $AuditJob.Id)
[array]$AuditSearchRecords = Invoke-MgGraphRequest -Uri $Uri -Method GET
[array]$AuditRecords = $AuditSearchRecords.value
$NextLink = $AuditSearchRecords.'@Odata.NextLink'
While ($null -ne $NextLink) {
$AuditSearchRecords = $null
[array]$AuditSearchRecords = Invoke-MgGraphRequest -Uri $NextLink -Method GET
$AuditRecords += $AuditSearchRecords.value
# Write-Host ("{0} audit records fetched so far..." -f $AuditRecords.count)
$NextLink = $AuditSearchRecords.'@odata.NextLink'
}
Write-Host ("Audit query {0} returned {1} records" -f $AuditJobName, $AuditRecords.Count)
If ($AuditRecords.Count -eq 0) {
Write-Output "No audit records found for the specified time period and operations."
Exit
} Else {
Write-Output ("Found {0} app management audit records for the specified time period" -f $AuditRecords.Count)
}
# Make sure that the audit records are sorted by date
$AuditRecords = $AuditRecords | Sort-Object CreatedDateTime -Descending
# Fetch all service principals to resolve names and populate a hash table for lookup
$SP = Get-MgServicePrincipal -All | Select-Object Id, DisplayName | Sort-Object DisplayName
$SPHash = @{}
ForEach ($ServicePrincipal in $SP) {
$SPHash.Add($ServicePrincipal.Id, $ServicePrincipal.DisplayName)
}
# Load the roles for the Microsoft Graph application to resolve app role assignments
$GraphApp = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
[array]$Roles = $GraphApp.AppRoles
$Report = [System.Collections.Generic.List[Object]]::new()
[int]$i = 0
ForEach ($Record in $AuditRecords) {
$i++
Switch ($Record.Operation) {
"Add app role assignment to service principal." {
$Record.ActivityDisplayName = "Add app role assignment to service principal"
$OperationDisplayName = "App role assignment added to service principal"
$RoleCheck = $Roles | Where-Object {$_.Id -eq $record.auditdata.modifiedproperties[0].NewValue.Trim() }
If ($RoleCheck) {
$GrantSource = "Microsoft Graph permission"
$SourceId = $RoleCheck.Id
$Permissions = $RoleCheck.DisplayName.Trim()
} Else {
$GrantSource = $Record.auditdata.modifiedproperties[0].NewValue.Trim()
$SourceId = $Record.AuditData.modifiedProperties[0].NewValue.Trim()
$Permissions = $Record.auditdata.modifiedproperties[1].NewValue.Trim()
}
$GrantedTo = $Record.auditdata.modifiedproperties[6].NewValue.Trim()
$SPIdGrantedTo = $Record.AuditData.modifiedProperties[5].NewValue.Trim()
}
"Add application." {
$OperationDisplayName = "New Application created"
$App = Get-MgApplication -ApplicationId $Record.Auditdata.Target[1].ID
$GrantedTo = $App.DisplayName
$SPIdGrantedTo = (Get-MgServicePrincipal -Filter "appId eq '$AppId'").Id
$Permissions = $null
$GrantSource = $null
$SourceId = $App.Id
}
"Add delegated permission grant." {
$OperationDisplayName = "App received delegated permission grant"
$GrantSource = $SPHash[$Record.AuditData.Target[1].id]
$SourceId = $Record.AuditData.Target[1].id
[array]$OldPermissions = $Record.AuditData.modifiedProperties[0].OldValue.Trim().Split(' ')
[array]$NewPermissions = $Record.AuditData.modifiedProperties[0].NewValue.Trim().Split(' ')
$Permissions = $NewPermissions | Where-Object { $_ -notin $OldPermissions }
[string]$Permissions = $Permissions -join ', '
$GrantedTo = $SPHash[$Record.AuditData.Target[0].id]
$GrantedTo = $SPHash[$Record.AuditData.modifiedProperties[2].NewValue]
$SPIdGrantedTo = $Record.AuditData.modifiedProperties[2].NewValue
}
# Yes, this operation does have a trailing space and a en dash (u+2013) in the name...
"Update application – Certificates and secrets management " {
# Extract details of app secret or certificate added or removed from the application
$Record.ActivityDisplayName = "Update application - Certificates and secrets management"
$OperationDisplayName = "Application updated with certificate or app secret"
$App = Get-MgApplication -ApplicationId $AppId -ErrorAction SilentlyContinue
$GrantedTo = $App.DisplayName
$SPIdGrantedTo = $null
$OldValue = $Record.auditdata.modifiedProperties[0].OldValue | ConvertFrom-Json
$NewValue = $Record.auditdata.modifiedProperties[0].NewValue | ConvertFrom-Json
$KeyString = $NewValue | Where-Object { $_ -notin $OldValue }
If ($null -eq $KeyString) {
$KeyString = "Can't determine new value for certificate or app secret"
} Else {
# Remove the brackets
$KeyString = $KeyString.Trim('[', ']')
# Split into key-value pairs
$Pairs = $KeyString -split ','
# Create a hashtable for the properties
$CertProps = @{}
Foreach ($Pair in $pairs) {
$kv = $Pair -split '=', 2
If ($kv.Count -eq 2) {
$key = $kv[0].Trim()
$value = $kv[1].Trim()
$CertProps[$key] = $value
}
}
# Generate the output string
$Permissions = ("Key Id: {0}, Key Type: {1}, Key Usage: {2}, Name: {3}" -f `
$CertProps['KeyIdentifier'], $CertProps['KeyType'], $CertProps['KeyUsage'], $CertProps['DisplayName'])
}
$GrantSource = $null
$SourceId = $null
}
"Consent to application." {
$OperationDisplayName = "Consent granted for permissions"
If ($Record.AuditData.modifiedProperties[0].NewValue -eq $true) {
$GrantSource = "Administrator consent"
} Else {
$SourceId = "User consent"
}
If ($Record.AuditData.modifiedProperties[4].NewValue -match 'Id:\s*([^\s,]+)') {
$OAuth2Delegation = $matches[1]
# Write-Host "Attempting to resolve OAuth2 delegation $OAuth2Delegation $i"
$Delegated = Get-MgOauth2PermissionGrant -OAuth2PermissionGrantId $OAuth2Delegation -ErrorAction SilentlyContinue
If ($Delegated) {
[array]$AssignedPermissions = $Delegated.Scope.trim().split(" ")
[string]$Permissions = $AssignedPermissions -join ', '
} Else {
$Permissions = "Unable to resolve delegated permissions"
}
}
$SourceId = $null
$GrantedTo = $SPHash[$Record.AuditData.Target[1].id]
}
Default { # Just in case we get an unexpected audit record
$OperationDisplayName = $Record.Operation
$GrantSource = $null
$SourceId = $null
$Permissions = $null
$GrantedTo = $null
$SPIdGrantedTo = $null
}
}
$ReportLine = [PSCustomObject] @{
CreatedDateTime = (Get-Date $Record.CreatedDateTime -format 'dd-MMM-yyyy HH:mm:ss')
Action = $OperationDisplayName
Application = $GrantedTo
User = $Record.UserPrincipalName
GrantSource = $GrantSource
SourceId = $SourceId
'New Permissions' = $Permissions
ServicePrincipalId = $SPIdGrantedTo
AuditRecordId = $Record.Id
Operation = $Record.Operation
}
$Report.Add($ReportLine)
}
$Report = $Report | Sort-Object {$_.CreatedDateTime -as [datetime]} -Descending
# Quick and dirty check if any of the apps have high-priority permissions assigned.
$ProblemApps = [System.Collections.Generic.List[Object]]::new()
[array]$HighPriorityPermissions = "User.Read.All", "User.ReadWrite.All", "Mail.ReadWrite", "Sites.Read.All", "Files.ReadWrite.All", "Calendars.ReadWrite", "Mail.Send", "User.Export.All", "Directory.Read.All", "Exchange.ManageAsApp", "Directory.ReadWrite.All", "Sites.ReadWrite.All"
ForEach ($R in $Report) {
[array]$PermissionsToCheck = $R.'New Permissions'.Split(' ')
[array]$HighPriorityPermissionsFound = $PermissionstoCheck | Where-Object { $_ -in $HighPriorityPermissions }
[string]$HighPriorityPermissionsFoundString = $HighPriorityPermissionsFound -join ', '
If ($HighPriorityPermissionsFoundString) {
$ReportLine = [PSCustomObject] @{
CreatedDateTime = $R.CreatedDateTime
Application = $R.Application
User = $R.User
GrantSource = $R.GrantSource
SourceId = $R.SourceId
'High Priority Permissions' = $HighPriorityPermissionsFoundString
Operation = $R.Operation
}
$ProblemApps.Add($ReportLine) }
}
# Define HTML style
$HtmlStyle = @"
<style>
body { font-family: Segoe UI, Arial, sans-serif; background: #f4f6f8; color: #222; }
h1 { background: #0078d4; color: #fff; padding: 16px; border-radius: 6px 6px 0 0; margin-bottom: 0; }
table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 0 0 6px 6px; overflow: hidden; }
th, td { padding: 10px 12px; text-align: left; }
th { background: #e5eaf1; color: #222; }
tr { background: #fff; color: #222; }
tr:nth-child(even) { background: #f0f4fa; color: #222; }
tr:hover { background: #d0e7fa; color: #222; }
.caption { font-size: 14px; color: #555; margin-bottom: 12px; }
</style>
"@
# Convert records to HTML table
$HtmlTable = $Report | Select-Object `
CreatedDateTime, Action, Application, User, GrantSource, SourceId, 'New Permissions', ServicePrincipalId |
ConvertTo-Html -Fragment -PreContent "<div class='caption'>Critical App Management Audit Events for Tenant Administrators to Review</div>"
# Generate warnings about high-priority permissions if any are found in apps
If ($ProblemApps) {
$HtmlTable2 = $ProblemApps | Select-Object `
CreatedDateTime, Application, User, 'High Priority Permissions', Operation |
ConvertTo-Html -Fragment -PreContent "<div class='caption'>Applications Detected with new High-Priority Permissions</div>"
} Else {
$HtmlTable2 = "<div class='caption'>No applications with high-priority permissions detected.</div>"
}
# Compose full HTML
$HtmlReport = @"
<html>
<head>
$HtmlStyle
<title>App Critical Audit Events Report</title>
</head>
<body>
<p>Report generated: $(Get-Date -Format 'dd-MMM-yyyy HH:mm')</p>
<h1>High Priority App Management Audit Events Detected</h1>
$HtmlTable2
<h1>Details of Critical App Management Audit Events</h1>
$HtmlTable
</body>
</html>
"@
$ReportFile = "$env:TEMP\CriticalAppManagementAuditEvents.html"
$CSVFile = "$env:TEMP\CriticalAppManagementAuditEvents.csv"
$HtmlReport | Out-File -FilePath $ReportFile -Encoding utf8
$Report | Export-Csv -Path $CSVFile -NoTypeInformation -Encoding UTF8
Write-Output ("Output files created: {0} and {1}" -f $ReportFile, $CSVFile)
$EncodedAttachmentFile = [Convert]::ToBase64String([IO.File]::ReadAllBytes($CSVFile))
$MsgAttachments = @(
@{
"@odata.type" = "#microsoft.graph.fileAttachment"
Name = ($CSVFile -split '\\')[-1]
ContentBytes = $EncodedAttachmentFile
}
)
# Build the array of a single TO recipient detailed in a hash table - make sure that you add your preferred email address here.
# The address can be any emailable object, including a user, group, or distribution list.
$ToRecipients = @{}
$ToRecipients.Add("emailAddress",@{'address'="tony.redmond@office365itpros.com"})
[array]$MsgTo = $ToRecipients
# Define the message subject
$MsgSubject = "Important: Critical App Management Audit Events Report"
$MsgBody = @{}
$MsgBody.Add('Content', "$($HtmlReport)")
$MsgBody.Add('ContentType','html')
$Message = @{}
$Message.Add('subject', $MsgSubject)
$Message.Add('toRecipients', $MsgTo)
$Message.Add('body', $MsgBody)
$Message.Add('attachments', $MsgAttachments)
$EmailParameters = @{}
$EmailParameters.Add('message', $Message)
$EmailParameters.Add('saveToSentItems', $true)
$EmailParameters.Add('isDeliveryReceiptRequested', $true)
# Send the message
Try {
Send-MgUserMail -UserId $MsgFrom -BodyParameter $EmailParameters -ErrorAction Stop
Write-Output "Report emailed to $($MsgTo.emailAddress.address)"
} Catch {
Write-Output ("Failed to send email report. Error: {0}" -f $_.Exception.Message)
}

Parameters

ParameterDefaultNotes
-AppId""Application (client) ID for the app registration used to connect.
-LookbackDays90Number of days back to include in the audit log query.
Attribution