Scripts/Get-UAL.ps1
$resultSize = 5000 function Get-UAL { <# .SYNOPSIS Gets all the unified audit log entries. .DESCRIPTION Makes it possible to extract all unified audit data out of a Microsoft 365 environment. The output will be written to: Output\UnifiedAuditLog\ .PARAMETER UserIds UserIds is the UserIds parameter filtering the log entries by the account of the user who performed the actions. .PARAMETER StartDate startDate is the parameter specifying the start date of the date range. Default: Today -180 days .PARAMETER EndDate endDate is the parameter specifying the end date of the date range. Default: Now .PARAMETER Output Output is the parameter specifying the CSV, JSON, or SOF-ELK output type. The SOF-ELK output can be imported into the platform of the same name. Default: CSV .PARAMETER OutputDir OutputDir is the parameter specifying the output directory. Default: Output\UnifiedAuditLog .PARAMETER MergeOutput MergeOutput is the parameter specifying if you wish to merge CSV/JSON/SOF-ELK outputs to a single file. .PARAMETER Encoding Encoding is the parameter specifying the encoding of the CSV/JSON output file. Default: UTF8 .PARAMETER ObjecIDs The ObjectIds parameter filters the log entries by object ID. The object ID is the target object that was acted upon, and depends on the RecordType and Operations values of the event. You can enter multiple values separated by commas. .DESCRIPTION Makes it possible to extract all unified audit data out of a Microsoft 365 environment. The output will be written to: Output\UnifiedAuditLog\ .PARAMETER Interval Interval is the parameter specifying the interval in which the logs are being gathered. .PARAMETER Group Group is the group of logging needed to be extracted. Options are: Exchange, Azure, Sharepoint, Skype and Defender .PARAMETER RecordType The RecordType parameter filters the log entries by record type. Options are: ExchangeItem, ExchangeAdmin, etc. A total of 353 RecordTypes are supported. .PARAMETER Operation The Operation parameter filters the log entries by operation or activity type. Options are: New-MailboxRule, MailItemsAccessed, etc. .PARAMETER LogLevel Specifies the level of logging: None: No logging Minimal: Critical errors only Standard: Normal operational logging Default: Standard .EXAMPLE Get-UAL Gets all the unified audit log entries. .EXAMPLE Get-UAL -UserIds Test@invictus-ir.com Gets all the unified audit log entries for the user Test@invictus-ir.com. .EXAMPLE Get-UAL -UserIds "Test@invictus-ir.com,HR@invictus-ir.com" Gets all the unified audit log entries for the users Test@invictus-ir.com and HR@invictus-ir.com. .EXAMPLE Get-UAL -UserIds Test@invictus-ir.com -StartDate 1/4/2024 -EndDate 5/4/2024 Gets all the unified audit log entries between 1/4/2024 and 5/4/2024 for the user Test@invictus-ir.com. .EXAMPLE Get-UAL -UserIds -Interval 720 Gets all the unified audit log entries with a time interval of 720. .EXAMPLE Get-UAL -UserIds Test@invictus-ir.com -MergeOutput Gets all the unified audit log entries for the user Test@invictus-ir.com and adds a combined output JSON file at the end of acquisition .EXAMPLE Get-UAL -UserIds Test@invictus-ir.com -Output JSON Gets all the unified audit log entries for the user Test@invictus-ir.com in JSON format. .EXAMPLE Get-UAL -Group Azure Gets the Azure related unified audit log entries. .EXAMPLE Get-UAL -RecordType ExchangeItem Gets the ExchangeItem logging from the unified audit log. .EXAMPLE Get-UAL -RecordType ExchangeItem -Group Azure Gets the ExchangeItem and all Azure related logging from the unified audit log. .EXAMPLE Get-UAL -Operation New-InboxRule Gets the New-InboxRule logging from the unified audit log. #> [CmdletBinding()] param ( [string]$StartDate, [string]$EndDate, [string]$UserIds = "*", [decimal]$Interval, [ValidateSet("Exchange", "Azure", "Sharepoint", "Skype", "Defender")] [string]$Group = $null, [array]$RecordType = $null, [array]$Operation = $null, [ValidateSet("CSV", "JSON", "SOF-ELK")] [string]$Output = "CSV", [switch]$MergeOutput, [string]$OutputDir, [string]$Encoding = "UTF8", [string]$ObjectIds, [ValidateSet('None', 'Minimal', 'Standard')] [string]$LogLevel = 'Standard' ) Set-LogLevel -Level ([LogLevel]::$LogLevel) $stats = @{ StartTime = Get-Date ProcessingTime = $null TotalRecords = 0 FilesCreated = 0 IntervalAdjustments = 0 } Write-LogFile -Message "=== Starting Unified Audit Log Collection ===" -Color "Cyan" -Level Minimal try { $areYouConnected = Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-1) -EndDate (Get-Date) -ResultSize 1 -ErrorAction Stop } catch { write-logFile -Message "[INFO] Ensure you are connected to M365 by running the Connect-M365 command before executing this script" -Color "Yellow" -Level Minimal Write-logFile -Message "[ERROR] An error occurred: $($_.Exception.Message)" -Color "Red" -Level Minimal throw } StartDateUAL -Quiet EndDate -Quiet $baseSearchQuery = @{ UserIds = $UserIds } if ($ObjectIds) { $baseSearchQuery.ObjectIds = $ObjectIds } if ($Operation) { $baseSearchQuery.Operations = $Operation } $totalResults = 0 $recordTypes = [System.Collections.ArrayList]::new() $date = [datetime]::Now.ToString('yyyyMMddHHmmss') if ($OutputDir -eq "") { $OutputDir = "Output\UnifiedAuditLog\$date" If (!(test-path $OutputDir)) { New-Item -ItemType Directory -Force -Name $OutputDir > $null } } else { if (!(Test-Path -Path $OutputDir)) { Write-Error "[Error] Custom directory invalid: $OutputDir exiting script" -ErrorAction Stop Write-LogFile -Message "[Error] Custom directory invalid: $OutputDir exiting script" -Level Minimal } } $GroupRecordTypes = @{ "Exchange" = @("ExchangeAdmin","ExchangeAggregatedOperation","ExchangeItem","ExchangeItemGroup", "ExchangeItemAggregated","ComplianceDLPExchange","ComplianceSupervisionExchange", "MipAutoLabelExchangeItem","ExchangeSearch","ComplianceDLPExchangeClassification","ComplianceCCExchangeExecutionResult", "CdpComplianceDLPExchangeClassification","ComplianceDLMExchange","ComplianceDLPExchangeDiscovery") "Azure" = @("AzureActiveDirectory","AzureActiveDirectoryAccountLogon","AzureActiveDirectoryStsLogon") "Sharepoint" = @("ComplianceDLPSharePoint","SharePoint","SharePointFileOperation","SharePointSharingOperation", "SharepointListOperation","ComplianceDLPSharePointClassification","SharePointCommentOperation", "SharePointListItemOperation","SharePointContentTypeOperation","SharePointFieldOperation", "MipAutoLabelSharePointItem","MipAutoLabelSharePointPolicyLocation","OnPremisesSharePointScannerDlp","SharePointSearch", "SharePointAppPermissionOperation","ComplianceDLPSharePointClassificationExtended","CdpComplianceDLPSharePointClassification", "SharePointESignature","ComplianceDLMSharePoint","SharePointContentSecurityPolicy") "Skype" = @("SkypeForBusinessCmdlets","SkypeForBusinessPSTNUsage","SkypeForBusinessUsersBlocked") "Defender" = @("ThreatIntelligence","ThreatFinder","ThreatIntelligenceUrl","ThreatIntelligenceAtpContent", "Campaign","AirInvestigation","WDATPAlerts","AirManualInvestigation", "AirAdminActionInvestigation","MSTIC","MCASAlerts") } if ($Group) { if ($null -eq $GroupRecordTypes[$Group]) { Write-LogFile -Message "[WARNING] Invalid input for -Group. Select Exchange, Azure, Sharepoint, Defender or Skype" -Color "Red" -Level Minimal return } $recordTypes.AddRange($GroupRecordTypes[$Group]) } if ($RecordType) { if ($RecordType -is [string]) { $recordTypesArray = $RecordType.Split(',').Trim() foreach ($item in $recordTypesArray) { [void]$recordTypes.Add($item) } } else { # Handle array input foreach ($item in $RecordType) { [void]$recordTypes.Add($item) } } } Write-LogFile -Message "Start date: $($script:StartDate.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Standard Write-LogFile -Message "End date: $($script:EndDate.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Standard Write-LogFile -Message "Output format: $Output" -Level Standard Write-LogFile -Message "Output Directory: $OutputDir" -Level Standard if ($recordTypes.Count -gt 0) { Write-LogFile -Message "`nThe following RecordType(s) are configured to be extracted:" -Level Standard foreach ($record in $recordTypes) { Write-LogFile -Message " - $record" -Level Standard } } if ($Operation) { Write-LogFile -Message "`nThe following Operation(s) are configured to be extracted:" -Level Standard foreach ($activity in $Operation) { Write-LogFile -Message "- $activity" -Level Standard } } Write-LogFile -Message "----------------------------------------`n" -Level Standard if ($recordTypes.Count -eq 0) { [void]$recordTypes.Add("*") } $maxRetries = 3 $baseDelay = 10 $retryCount = 0 foreach ($record in $recordTypes) { if ($record -ne "*") { Write-LogFile -Message "=== Processing RecordType: $record ===" -Color "Cyan" -Level Standard $baseSearchQuery.RecordType = $record } else { $baseSearchQuery.Remove('RecordType') } $retryAttempt = 0 $success = $false while (!$success -and $retryAttempt -lt $maxRetries) { try { $totalResults = Search-UnifiedAuditLog -StartDate $script:StartDate -EndDate $script:EndDate @baseSearchQuery -ResultSize 1 | Select-Object -First 1 -ExpandProperty ResultCount if ($null -ne $totalResults) { $message = if ($record -eq "*") { "[INFO] Total number of events during the acquisition period: $totalResults" } else { "[INFO] The record '$record' contains $totalResults events during the acquisition period" } Write-LogFile -Message $message -Level Standard -color "Green" } $success = $true } catch { if ($_.Exception.Message -like "*server side error*" -or $_.Exception.Message -like "*operation could not be completed*") { $retryAttempt++ if ($retryAttempt -eq $maxRetries) { Write-LogFile -Message "[ERROR] Maximum retry attempts reached for initial count. Last error: $($_.Exception.Message)" -Color "Red" -Level Minimal throw } Write-LogFile -Message "[WARNING] Server-side error on initial count attempt $retryAttempt of $maxRetries. Waiting $baseDelay seconds..." -Color "Yellow" -Level Minimal Start-Sleep -Seconds $baseDelay $baseDelay *= 2 continue } else { throw } } } if ($null -eq $totalResults -or $totalResults -eq 0) { $message = if ($record -eq "*") { "[INFO] No records found!" } else { "[INFO] No records found for RecordType: $record" } Write-LogFile -Message "[INFO] No records found for RecordType: $record" -Level Standard -Color "Yellow" continue } if (!$PSBoundParameters.ContainsKey('Interval')) { $totalMinutes = ($script:EndDate - $script:StartDate).TotalMinutes $estimatedIntervals = [math]::Ceiling($totalResults / 50000) if ($estimatedIntervals -eq 0) { $Interval = $totalMinutes } else { $Interval = [math]::Max(1, [math]::Floor($totalMinutes / $estimatedIntervals)) } } $resetInterval = $Interval [DateTime]$currentStart = $script:StartDate [DateTime]$currentEnd = $script:EndDate $finalEndDate = $script:EndDate.ToUniversalTime() $maxRetries = 3 $baseDelay = 10 $retryCount = 0 while ($currentStart -lt $finalEndDate) { $currentEnd = $currentStart.AddMinutes($Interval) if ($currentEnd -gt $finalEndDate) { $currentEnd = $finalEndDate } if ($currentEnd -le $currentStart) { Write-LogFile -Message "[INFO] Reached end of date range" -Level Standard break } $retryAttempt = 0 $currentDelay = $baseDelay $success = $false while (!$success -and $retryAttempt -lt $maxRetries) { try { $amountResults = Search-UnifiedAuditLog -StartDate $currentStart -EndDate $currentEnd @baseSearchQuery -ResultSize 1 | Select-Object -First 1 -ExpandProperty ResultCount if ($null -eq $amountResults) { $retryAttempt = 0 $maxNullRetries = 3 $success = $false while (!$success -and $retryAttempt -lt $maxNullRetries) { Start-Sleep -Seconds (5 * ($retryAttempt + 1)) try { # Try with a different session ID $tempSessionId = [Guid]::NewGuid().ToString() $verifyResult = Search-UnifiedAuditLog -StartDate $currentStart -EndDate $currentEnd ` @baseSearchQuery -ResultSize 1 -SessionId $tempSessionId if ($null -ne $verifyResult) { $amountResults = $verifyResult | Select-Object -First 1 -ExpandProperty ResultCount $success = $true break } } catch { Write-LogFile -Message "[WARNING] Retry attempt $($retryAttempt + 1) failed for period verification" -Level Standard } $retryAttempt++ } if ($null -eq $amountResults) { if ($currentStart -ne $currentEnd) { Write-LogFile -Message "[INFO] No audit logs between $($currentStart.ToString('yyyy-MM-dd HH:mm:ss')) and $($currentEnd.ToString('yyyy-MM-dd HH:mm:ss')). Moving on!" -Level Standard } $CurrentStart = $CurrentEnd $success = $true } } elseif ($amountResults -gt 50000) { while ($amountResults -gt 50000) { $amountResults = Search-UnifiedAuditLog -StartDate $currentStart -EndDate $currentEnd @baseSearchQuery -ResultSize 1 | Select-Object -First 1 -ExpandProperty ResultCount $oldInterval = $Interval if ($amountResults -gt 50000) { $stats.IntervalAdjustments++ if ($amountResults -gt 1000000) { $divisor = ($amountResults/50000) * 4 } elseif ($amountResults -gt 500000) { $divisor = ($amountResults/50000) * 3 } elseif ($amountResults -gt 200000) { $divisor = ($amountResults/50000) * 2 } elseif ($amountResults -gt 100000) { $divisor = ($amountResults/50000) * 1.5 } else { $divisor = ($amountResults/50000) * 1.25 } $newInterval = [math]::Max([math]::Round(($Interval/$divisor), 2), 0.1) $calculatedInterval = $Interval/$divisor $newInterval = if ($calculatedInterval -lt 1) { [math]::Max([math]::Round($calculatedInterval, 3), 0.1) } else { [math]::Max([math]::Round($calculatedInterval, 0), 1) } # Safety check to prevent getting stuck if ($newInterval -ge $oldInterval) { $newInterval = [math]::Max($Interval * 0.5, 1) } $Interval = $newInterval Write-LogFile -Message "[WARNING] $amountResults entries between $($currentStart.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssK")) and $($currentEnd.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssK")) exceeding the maximum of 50000 entries" -Color "Red" -Level Standard Write-LogFile -Message "[INFO] Temporary lowering time interval from $oldInterval to $newInterval minutes" -Color "Yellow" -Level Standard $currentEnd = $currentStart.AddMinutes($Interval) } elseif ($amountResults -eq 0) { # Double check with a smaller result size $verifyResults = Search-UnifiedAuditLog -StartDate $currentStart -EndDate $currentEnd @baseSearchQuery -ResultSize 1 if ($null -ne $verifyResults) { # If we find results, adjust interval and retry $Interval = [math]::Max($Interval * 0.5, 1) $currentEnd = $currentStart.AddMinutes($Interval) continue } # Break the loop if no results are found Write-LogFile -Message "[INFO] No results found in this time period, moving to next interval" -Level Standard $currentEnd = $currentStart.AddMinutes($Interval) } if ($Interval -eq 0) { Exit } } } elseif ($amountResults -gt 0) { $Interval = $resetInterval if ($currentEnd -gt $script:EndDate) { $currentEnd = $script:EndDate } if ($null -eq $amountResults) { break } Write-LogFile -Message "[INFO] Found $amountResults audit logs between $($currentStart.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssK")) and $($currentEnd.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssK"))" -Level Standard -Color "Green" $retryAttempt = 0 $currentDelay = $baseDelay $success = $false while (!$success -and $retryAttempt -lt $maxRetries) { try { do { $sessionID = $currentStart.ToString("yyyyMMddHHmmss") $batchSuccess = $false $batchAttempts = 0 $maxBatchRetries = 3 [Array]$allResults = @() $totalProcessed = 0 $backoffDelay = 10 while (!$batchSuccess -and $batchAttempts -lt $maxBatchRetries) { try { $allResults = @() $totalProcessed = 0 while ($totalProcessed -lt $amountResults) { [Array]$results = Search-UnifiedAuditLog -StartDate $CurrentStart -EndDate $currentEnd -SessionCommand ReturnLargeSet -SessionId $sessionId -ResultSize $resultSize @baseSearchQuery if ($null -ne $results -and $results.Count -gt 0) { $allResults += $results $totalProcessed += $results.Count Write-LogFile -Message "[INFO] Retrieved $($results.Count) records (Total: $totalProcessed / $amountResults)" -Level Standard $backoffDelay = 10 } else { Write-LogFile -Message "[WARNING] Microsoft returned corrupt data for the period $($currentStart.ToString('yyyy-MM-dd HH:mm:ss')) to $($currentEnd.ToString('yyyy-MM-dd HH:mm:ss'))... Retrying the entire batch... " -Color "Yellow" -Level Minimal $batchAttempts++ $allResults = @() $totalProcessed = 0 $sessionId = [Guid]::NewGuid().ToString() Start-Sleep -Seconds $backoffDelay $backoffDelay = [Math]::Min(30, $backoffDelay * 2) break } } if ($totalProcessed -eq $amountResults) { $batchSuccess = $true } } catch { if ($_.Exception.Message -like "*server side error*" -or $_.Exception.Message -like "*operation could not be completed*" -or $_.Exception.Message -like "*timed out*") { Write-LogFile -Message "[WARNING] Server error encountered. Restarting entire batch." -Color "Yellow" -Level Standard $allResults = @() $totalProcessed = 0 $sessionId = [Guid]::NewGuid().ToString() Start-Sleep -Seconds $backoffDelay $backoffDelay = [Math]::Min(30, $backoffDelay * 2) continue } else { Write-LogFile -Message "[ERROR] Unexpected error: $($_.Exception.Message)" -Color "Red" -Level Standard } } } } while ($totalProcessed -lt $amountResults -and $batchSuccess -eq $false) if ($totalProcessed -ne $amountResults) { Write-LogFile -Message "[WARNING] Retrieved record count ($totalProcessed) differs from expected ($amountResults). Retrying entire batch." -Level Standard -Color "Yellow" $allResults = @() $totalProcessed = 0 $sessionId = [Guid]::NewGuid().ToString() continue } else { $success = $true if ($totalProcessed -gt 0) { $sessionID = $currentStart.ToString("yyyyMMddHHmmss") + "-" + $currentEnd.ToString("yyyyMMddHHmmss") $outputPath = Join-Path $OutputDir ("UAL-" + $sessionID) $stats.TotalRecords += $totalProcessed if ($Output -eq "JSON" -or $Output -eq "SOF-ELK") { $stats.FilesCreated++ $allResults = $allResults | ForEach-Object { $_.AuditData = $_.AuditData | ConvertFrom-Json $_ } if ($Output -eq "JSON") { $json = $allResults | ConvertTo-Json -Depth 100 $json | Out-File -Append "$OutputDir/UAL-$sessionID.json" -Encoding $Encoding } elseif ($Output -eq "SOF-ELK") { # Encoding is hard-coded to UTF8 as UTF16 causes problems when importing the data into SOF-ELK foreach ($item in $allResults) { $item.AuditData | ConvertTo-Json -Compress -Depth 100 | Out-File -Append "$OutputDir/UAL-$sessionID.json" -Encoding UTF8 } } Add-Content "$OutputDir/UAL-$sessionID.json" "`n" } elseif ($Output -eq "CSV") { $stats.FilesCreated++ $allResults | export-CSV "$outputPath.csv" -NoTypeInformation -Append -Encoding $Encoding } Write-LogFile -Message "[INFO] Successfully retrieved $totalProcessed records for the current time range. Moving on!" -Level Minimal -Color "Green" } } } catch { if ($_.Exception.Message -like "*server side error*" -or $_.Exception.Message -like "*operation could not be completed*") { $retryAttempt++ if ($retryAttempt -eq $maxRetries) { Write-LogFile -Message "[ERROR] Maximum retry attempts reached for interval check. Last error: $($_.Exception.Message)" -Color "Red" -Level Minimal throw } Write-LogFile -Message "[WARNING] Server-side error on attempt $retryAttempt of $maxRetries. Waiting $currentDelay seconds..." -Color "Yellow" -Level Minimal Start-Sleep -Seconds $currentDelay $currentDelay *= 2 continue } else { throw } } } $CurrentStart = $CurrentEnd } } catch { if ($_.Exception.Message -like "*server side error*" -or $_.Exception.Message -like "*operation could not be completed*") { $retryAttempt++ if ($retryAttempt -eq $maxRetries) { Write-LogFile -Message "[ERROR] Maximum retry attempts reached for interval check. Last error: $($_.Exception.Message)" -Color "Red" -Level Minimal throw } Write-LogFile -Message "[WARNING] Server-side error on attempt $retryAttempt of $maxRetries. Waiting $currentDelay seconds..." -Color "Yellow" -Level Minimal Start-Sleep -Seconds $currentDelay $currentDelay *= 2 continue } else { throw } } } } if ($Output -eq "CSV" -and ($MergeOutput.IsPresent)) { Write-LogFile -Message "[INFO] Merging output files into one file" -Level standard Merge-OutputFiles -OutputDir $OutputDir -OutputType "CSV" -MergedFileName "UAL-Combined.csv" } elseif ($Output -eq "JSON" -and ($MergeOutput.IsPresent)) { Write-LogFile -Message "[INFO] Merging output files into one file" -Level standard Merge-OutputFiles -OutputDir $OutputDir -OutputType "JSON" -MergedFileName "UAL-Combined.json" } elseif ($Output -eq "SOF-ELK" -and ($MergeOutput.IsPresent)) { Write-LogFile -Message "[INFO] Merging output files into one file" -Level standard Merge-OutputFiles -OutputDir $OutputDir -OutputType "SOF-ELK" -MergedFileName "UAL-Combined.json" } } $stats.ProcessingTime = (Get-Date) - $stats.StartTime Write-LogFile -Message "`n=== Collection Summary ===" -Color "Cyan" -Level Standard Write-LogFile -Message "Start date: $($script:StartDate.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Standard Write-LogFile -Message "End date: $($script:EndDate.ToString('yyyy-MM-dd HH:mm:ss'))" -Level Standard Write-LogFile -Message "Total Records: $($stats.TotalRecords)" -Level Standard Write-LogFile -Message "Files Created: $($stats.FilesCreated)" -Level Standard Write-LogFile -Message "Interval Adjustments: $($stats.IntervalAdjustments)" -Level Standard Write-LogFile -Message "Output Directory: $OutputDir" -Level Standard Write-LogFile -Message "Processing Time: $($stats.ProcessingTime.ToString('hh\:mm\:ss'))" -Level Standard -Color "Green" Write-LogFile -Message "===================================" -Color "Cyan" -Level Standard } |