Entra / Microsoft 365 · Compliance & audit
Report audit events via email runbook
Azure Automation runbook that queries the audit log and sends email about audit events; can also run interactively with app-only Graph authentication.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -Identity
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([int] $LookbackDays = 7,[string] $StartDate = (Get-Date).AddDays(-$LookbackDays),[string] $EndDate = (Get-Date).AddDays(1))Connect-MgGraph -IdentitySet-MgRequestContext -MaxRetry 10 -RetryDelay 15$AuditQueryName = ("Azure Automation Runbook Scan created at {0}" -f (Get-Date -format 'dd-MMM-yyyy HH:mm'))$AuditQueryStart = (Get-Date $StartDate -format s)$AuditQueryEnd = (Get-Date $EndDate -format s)[array]$AuditQueryOperations = "Update user."$AuditQueryParameters = @{}#$AuditQueryParameters.Add("@odata.type","#microsoft.graph.security.auditLogQuery")$AuditQueryParameters.Add("displayName", $AuditQueryName)$AuditQueryParameters.Add("OperationFilters", $AuditQueryOperations)$AuditQueryParameters.Add("filterStartDateTime", $AuditQueryStart)$AuditQueryParameters.Add("filterEndDateTime", $AuditQueryEnd)# Submit the audit query$AuditJob = New-MgBetaSecurityAuditLogQuery -BodyParameter $AuditQueryParameters# Check the audit query status every 20 seconds until it completes[int]$i = 1[int]$SleepSeconds = 20$SearchFinished = $false; [int]$SecondsElapsed = 20Write-Host "Checking audit query status..."Start-Sleep -Seconds 30# This cmdlet is not working...#$AuditQueryStatus = Get-MgBetaSecurityAuditLogQuery -AuditLogQueryId $AuditJob.Id$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}" -f $AuditJob.id)$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method GetWhile ($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 = Get-MgBetaSecurityAuditLogQuery -AuditLogQueryId $AuditJob.Id$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method Get}}# Fetch the audit records returned by the query# This cmdlet isn't working either# [array]$AuditRecords = Get-MgBetaSecurityAuditLogQueryRecord -AuditLogQueryId $AuditJob.Id -All -PageSize 999$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}/records" -f $AuditJob.Id)[array]$SearchRecords = Invoke-MgGraphRequest -Uri $Uri -Method GET[array]$AuditRecords = $SearchRecords.value$NextLink = $SearchRecords.'@Odata.NextLink'While ($null -ne $NextLink) {$SearchRecords = $null[array]$SearchRecords = Invoke-MgGraphRequest -Uri $NextLink -Method GET$AuditRecords += $SearchRecords.valueWrite-Host ("{0} audit records fetched so far..." -f $AuditRecords.count)$NextLink = $SearchRecords.'@odata.NextLink'}Write-Host ("Audit query {0} returned {1} records" -f $AuditQueryName, $AuditRecords.Count)$Report = [System.Collections.Generic.List[Object]]::new()ForEach ($Rec in $AuditRecords) {$AuditData = $Rec.AuditDataIf ('UserPrincipalName' -in $rec.AuditData.ModifiedProperties.Name) {# Extract the values for the old and new UPNs from the Auditdata payload$OldUPN = $AuditData.ModifiedProperties | Where-Object {$_.Name -eq 'UserPrincipalName'} | Select-Object -ExpandProperty OldValue$MatchedData = $OldUPN -match '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' | Out-Null$OldUPN = $Matches[0]$NewUPN = $AuditData.ModifiedProperties | Where-Object {$_.Name -eq 'UserPrincipalName'} | Select-Object -ExpandProperty NewValue$MatchedData = $NewUPN -match '\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' | Out-Null$NewUPN = $Matches[0]If ($OldUPN -ne $NewUPN) {$ReportLine = [PSCustomObject][Ordered]@{Timestamp = $Rec.CreatedDateTimeActor = $Rec.userPrincipalNameOperation = $Rec.operationOldValue = $OldUPNNewValue = $NewUPN}$Report.Add($ReportLine)}}}$Report | Sort-Object {$_.Timestamp -as [datetime]} | Format-Table -AutoSize$MsgFrom = 'Customer.Services@office365itpros.com'# Define some variables used to construct the HTML content in the message body# HTML header with styles$HtmlHead="<html><style>BODY{font-family: Arial; font-size: 10pt;}H1{font-size: 22px;}H2{font-size: 18px; padding-top: 10px;}H3{font-size: 16px; padding-top: 8px;}H4{font-size: 8px; padding-top: 4px;}</style>"$MsgSubject = "Audit Events for Your Review"$ToRecipients = @{}$ToRecipients.Add("emailAddress",@{'address'='tony.redmond@office365itpros.com'})[array]$MsgTo = $ToRecipients# Customize the message$HtmlHeader = "<p><h2>Administrative alert: User Principal Name Updates in the last week</h2></p>"# Add some content for the message - obviously, this is very customizable and should reflect what you want to say about the data being reported$HtmlBody = "<h1>Please Check these audit events</h1><p></p>"$HtmlBody = $HtmlBody + ($Report | ConvertTo-Html -Fragment)$HtmlBody = $HtmlBody + "<p>These audit records are highlighted because of the impact a change to a user principal name can have on systems.</p>"$HtmlBody = $HtmlBody + "<p><h4>Generated:</strong> $(Get-Date -Format 'dd-MMM-yyyy HH:mm')</h4></p>"$HtmlMsg = $HtmlHead + $HtmlHeader + $HtmlBody + "<p>"# Construct the message body$MsgBody = @{}$MsgBody.Add('Content', "$($HtmlMsg)")$MsgBody.Add('ContentType','html')$Message = @{}$Message.Add('subject', $MsgSubject)$Message.Add('toRecipients', $MsgTo)$Message.Add('body', $MsgBody)$Params = @{}$Params.Add('message', $Message)$Params.Add('saveToSentItems', $true)$Params.Add('isDeliveryReceiptRequested', $true)Send-MgUserMail -UserId $MsgFrom -BodyParameter $Params
Parameters
ParameterDefaultNotes
-LookbackDays7Number of days back to include in the audit log query.-StartDate(Get-Date).AddDays(-7)Start of the reporting window.-EndDate(Get-Date).AddDays(1)End of the reporting window.Attribution
Author
Office365itpros