Microsoft Purview PowerShell script Report only

Search-PurviewAuditLog.ps1

Exports unified audit log records reliably, beyond the 5,000-row limit of a single Search-UnifiedAuditLog call.

Overview#

Slices the requested window into -IntervalHours blocks and, for each block, pages through Search-UnifiedAuditLog with -SessionCommand ReturnLargeSet until the service reports that every record has been returned. Records are de-duplicated by Identity, the AuditData JSON is parsed, and common fields (Workload, ClientIP, ObjectId, ResultStatus, UserAgent, UserType) are flattened into columns while the raw AuditData JSON is kept for deeper analysis. Optional filters cover users, operations, record type, free text and IP addresses. Writes a CSV and prints a summary by operation and workload. The script is read-only.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Search-PurviewAuditLog.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-StartDateStart of the search window. Defaults to 7 days ago. Must be within the audit retention period (180 days by default).
-EndDateEnd of the search window. Defaults to now and must be later than StartDate.
-UserIdsOne or more user principal names to filter on.
-OperationsOne or more operations, for example FileDownloaded, UserLoggedIn, 'Add member to role.'.
-RecordTypeA single audit record type, for example ExchangeItem, SharePointFileOperation or AzureActiveDirectoryStsLogon.
-FreeTextFree-text filter applied by the service to the audit record.
-IPAddressesOne or more client IP addresses to filter on.
-IntervalHoursSize of each time slice in hours (default 24). Use smaller slices for busy tenants so that no slice exceeds the 50,000 records that a ReturnLargeSet session can page through.
-ResultSizePage size per Search-UnifiedAuditLog call, 1-5000 (default 5000).
-OutputPathPath of the CSV report. Defaults to .\Reports\PurviewAuditLog_yyyyMMdd-HHmm.csv.
-PassThruAlso emit the flattened records to the pipeline.

Examples#

PowerShell
PS> .\Search-PurviewAuditLog.ps1

Exports the last 7 days of audit records for the whole tenant to .\Reports\PurviewAuditLog_<timestamp>.csv.

PowerShell
PS> .\Search-PurviewAuditLog.ps1 -StartDate (Get-Date).AddDays(-30) -UserIds alex@contoso.com -Operations UserLoggedIn, FileDownloaded -IntervalHours 12 -Verbose

Exports 30 days of sign-in and download events for one user in 12-hour slices.

PowerShell
PS> .\Search-PurviewAuditLog.ps1 -RecordType AzureActiveDirectoryStsLogon -IPAddresses 203.0.113.10 -PassThru | Group-Object UserIds | Sort-Object Count -Descending

Shows who signed in from a specific IP address during the last 7 days.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x Permissions : Audit Logs or View-Only Audit Logs role (Exchange Online / Purview); auditing must be enabled (Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled) Notes : Search-UnifiedAuditLog is an Exchange Online cmdlet, so the script opens an Exchange Online session, not a Security & Compliance one. Audit (Standard) retains 180 days; Audit (Premium) retention policies can keep selected records for up to 10 years. Dates are interpreted by the service as UTC and CreationDate is returned in UTC. Searches are throttled per tenant - avoid running several exports at the same time, and prefer smaller -IntervalHours over huge single sessions.

Full source#

PowerShell · Search-PurviewAuditLog.ps1
<#
.SYNOPSIS
    Exports unified audit log records reliably, beyond the 5,000-row limit of a single Search-UnifiedAuditLog call.
.DESCRIPTION
    Slices the requested window into -IntervalHours blocks and, for each block, pages through
    Search-UnifiedAuditLog with -SessionCommand ReturnLargeSet until the service reports that every record has
    been returned. Records are de-duplicated by Identity, the AuditData JSON is parsed, and common fields
    (Workload, ClientIP, ObjectId, ResultStatus, UserAgent, UserType) are flattened into columns while the raw
    AuditData JSON is kept for deeper analysis. Optional filters cover users, operations, record type, free text
    and IP addresses. Writes a CSV and prints a summary by operation and workload. The script is read-only.
.PARAMETER StartDate
    Start of the search window. Defaults to 7 days ago. Must be within the audit retention period (180 days by default).
.PARAMETER EndDate
    End of the search window. Defaults to now and must be later than StartDate.
.PARAMETER UserIds
    One or more user principal names to filter on.
.PARAMETER Operations
    One or more operations, for example FileDownloaded, UserLoggedIn, 'Add member to role.'.
.PARAMETER RecordType
    A single audit record type, for example ExchangeItem, SharePointFileOperation or AzureActiveDirectoryStsLogon.
.PARAMETER FreeText
    Free-text filter applied by the service to the audit record.
.PARAMETER IPAddresses
    One or more client IP addresses to filter on.
.PARAMETER IntervalHours
    Size of each time slice in hours (default 24). Use smaller slices for busy tenants so that no slice exceeds
    the 50,000 records that a ReturnLargeSet session can page through.
.PARAMETER ResultSize
    Page size per Search-UnifiedAuditLog call, 1-5000 (default 5000).
.PARAMETER OutputPath
    Path of the CSV report. Defaults to .\Reports\PurviewAuditLog_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
    Also emit the flattened records to the pipeline.
.EXAMPLE
    PS> .\Search-PurviewAuditLog.ps1
    Exports the last 7 days of audit records for the whole tenant to .\Reports\PurviewAuditLog_<timestamp>.csv.
.EXAMPLE
    PS> .\Search-PurviewAuditLog.ps1 -StartDate (Get-Date).AddDays(-30) -UserIds alex@contoso.com -Operations UserLoggedIn, FileDownloaded -IntervalHours 12 -Verbose
    Exports 30 days of sign-in and download events for one user in 12-hour slices.
.EXAMPLE
    PS> .\Search-PurviewAuditLog.ps1 -RecordType AzureActiveDirectoryStsLogon -IPAddresses 203.0.113.10 -PassThru | Group-Object UserIds | Sort-Object Count -Descending
    Shows who signed in from a specific IP address during the last 7 days.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x
    Permissions : Audit Logs or View-Only Audit Logs role (Exchange Online / Purview); auditing must be enabled
                  (Get-AdminAuditLogConfig | Select-Object UnifiedAuditLogIngestionEnabled)
    Notes       : Search-UnifiedAuditLog is an Exchange Online cmdlet, so the script opens an Exchange Online session,
                  not a Security & Compliance one. Audit (Standard) retains 180 days; Audit (Premium) retention policies
                  can keep selected records for up to 10 years. Dates are interpreted by the service as UTC and
                  CreationDate is returned in UTC. Searches are throttled per tenant - avoid running several exports
                  at the same time, and prefer smaller -IntervalHours over huge single sessions.
.LINK
    https://learn.microsoft.com/powershell/module/exchange/search-unifiedauditlog
.LINK
    https://learn.microsoft.com/purview/audit-log-search-script
#>
#Requires -Version 5.1
#Requires -Modules ExchangeOnlineManagement

[CmdletBinding()]
param(
    [Parameter()]
    [datetime]$StartDate = (Get-Date).AddDays(-7),

    [Parameter()]
    [datetime]$EndDate = (Get-Date),

    [Parameter()]
    [string[]]$UserIds,

    [Parameter()]
    [string[]]$Operations,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$RecordType,

    [Parameter()]
    [string]$FreeText,

    [Parameter()]
    [string[]]$IPAddresses,

    [Parameter()]
    [ValidateRange(1, 720)]
    [int]$IntervalHours = 24,

    [Parameter()]
    [ValidateRange(1, 5000)]
    [int]$ResultSize = 5000,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-ExchangeIfNeeded {
    <# Connects to Exchange Online (or Security & Compliance PowerShell) only when no live session exists. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [switch]$Compliance
    )
    $connections = @(Get-ConnectionInformation -ErrorAction SilentlyContinue)
    if ($Compliance) {
        $active = @($connections | Where-Object { $_.ConnectionUri -like '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Security & Compliance PowerShell.'
            Connect-IPPSSession -ErrorAction Stop
        }
    }
    else {
        $active = @($connections | Where-Object { $_.ConnectionUri -notlike '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Exchange Online.'
            Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
        }
    }
}

function Get-AuditField {
    <# Returns the first non-empty value among the given property names of a parsed AuditData object; workloads name the same field differently. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        $AuditData,

        [Parameter(Mandatory = $true)]
        [string[]]$Names
    )
    if ($null -eq $AuditData) { return $null }
    foreach ($name in $Names) {
        $property = $AuditData.PSObject.Properties[$name]
        if ($null -ne $property -and -not [string]::IsNullOrEmpty([string]$property.Value)) { return $property.Value }
    }
    return $null
}
#endregion Helpers

#region Main
if ($EndDate -le $StartDate) { throw 'EndDate must be later than StartDate.' }
if ($StartDate -lt (Get-Date).AddDays(-180)) {
    Write-Warning 'StartDate is more than 180 days ago. Audit (Standard) keeps records for 180 days; older events are only returned when Audit (Premium) retention policies apply.'
}

if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('PurviewAuditLog_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

try {
    Connect-ExchangeIfNeeded
}
catch {
    throw "Unable to connect to Exchange Online: $($_.Exception.Message)"
}

try {
    $auditConfig = Get-AdminAuditLogConfig -ErrorAction Stop
    if ($auditConfig.UnifiedAuditLogIngestionEnabled -ne $true) {
        Write-Warning 'Unified audit log ingestion is disabled for this tenant. Enable it with Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true; searches return nothing until then.'
    }
}
catch {
    Write-Verbose "Could not read the audit configuration (requires the Organization Configuration role): $($_.Exception.Message)"
}

$filterParams = @{}
if ($PSBoundParameters.ContainsKey('UserIds')) { $filterParams['UserIds'] = $UserIds }
if ($PSBoundParameters.ContainsKey('Operations')) { $filterParams['Operations'] = $Operations }
if ($PSBoundParameters.ContainsKey('RecordType')) { $filterParams['RecordType'] = $RecordType }
if ($PSBoundParameters.ContainsKey('FreeText')) { $filterParams['FreeText'] = $FreeText }
if ($PSBoundParameters.ContainsKey('IPAddresses')) { $filterParams['IPAddresses'] = $IPAddresses }

# A ReturnLargeSet session can page through at most 50,000 records; this caps the paging loop per slice.
$maxPages = [int][math]::Ceiling(50000 / $ResultSize)
$totalSlices = [int][math]::Ceiling(($EndDate - $StartDate).TotalHours / $IntervalHours)
$records = New-Object -TypeName System.Collections.Generic.List[object]
$seen = New-Object -TypeName 'System.Collections.Generic.HashSet[string]'
$sliceIndex = 0
$sliceStart = $StartDate
while ($sliceStart -lt $EndDate) {
    $sliceIndex++
    $sliceEnd = $sliceStart.AddHours($IntervalHours)
    if ($sliceEnd -gt $EndDate) { $sliceEnd = $EndDate }
    $status = 'Slice {0} of {1}: {2:yyyy-MM-dd HH:mm} to {3:yyyy-MM-dd HH:mm} ({4} records so far)' -f $sliceIndex, $totalSlices, $sliceStart, $sliceEnd, $records.Count
    Write-Progress -Activity 'Searching the unified audit log' -Status $status -PercentComplete ((($sliceIndex - 1) / $totalSlices) * 100)

    # A new SessionId per slice starts a fresh paged result set on the service side.
    $searchParams = @{
        StartDate      = $sliceStart
        EndDate        = $sliceEnd
        SessionId      = [guid]::NewGuid().ToString()
        SessionCommand = 'ReturnLargeSet'
        ResultSize     = $ResultSize
        ErrorAction    = 'Stop'
    } + $filterParams
    $expectedCount = 0
    $sliceRecords = 0
    $page = 1
    do {
        try {
            $batch = @(Search-UnifiedAuditLog @searchParams)
        }
        catch {
            Write-Warning ('Slice {0} ({1:yyyy-MM-dd HH:mm} to {2:yyyy-MM-dd HH:mm}), page {3} failed: {4}' -f $sliceIndex, $sliceStart, $sliceEnd, $page, $_.Exception.Message)
            break
        }
        if ($batch.Count -eq 0) { break }
        if ($page -eq 1) {
            $expectedCount = [int]$batch[0].ResultCount
            if ($expectedCount -ge 50000) {
                Write-Warning ('Slice {0} holds {1} records but ReturnLargeSet pages through at most 50,000. Re-run with a smaller -IntervalHours to capture everything.' -f $sliceIndex, $expectedCount)
            }
        }
        foreach ($entry in $batch) {
            if ($seen.Add([string]$entry.Identity)) { $records.Add($entry) }
        }
        $sliceRecords += $batch.Count
        Write-Verbose ('Slice {0}, page {1}: {2} records ({3} of {4}).' -f $sliceIndex, $page, $batch.Count, $sliceRecords, $expectedCount)
        $page++
    } while ($sliceRecords -lt $expectedCount -and $page -le $maxPages)
    $sliceStart = $sliceEnd
}
Write-Progress -Activity 'Searching the unified audit log' -Completed

# UserType in AuditData is numeric; these are the documented values.
$userTypeNames = @{ '0' = 'Regular'; '1' = 'Reserved'; '2' = 'Admin'; '3' = 'DcAdmin'; '4' = 'System'; '5' = 'Application'; '6' = 'ServicePrincipal'; '7' = 'CustomPolicy'; '8' = 'SystemPolicy' }
$results = New-Object -TypeName System.Collections.Generic.List[object]
foreach ($record in $records) {
    $audit = $null
    try {
        $audit = [string]$record.AuditData | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        Write-Verbose "Could not parse AuditData for record $($record.Identity): $($_.Exception.Message)"
    }

    $userAgent = Get-AuditField -AuditData $audit -Names 'UserAgent', 'ClientInfoString'
    if ($null -eq $userAgent -and $null -ne $audit -and $null -ne $audit.PSObject.Properties['ExtendedProperties']) {
        # Entra ID sign-in events carry the user agent inside ExtendedProperties.
        $extended = @($audit.ExtendedProperties | Where-Object { $_.Name -eq 'UserAgent' }) | Select-Object -First 1
        if ($null -ne $extended) { $userAgent = $extended.Value }
    }
    $userType = [string](Get-AuditField -AuditData $audit -Names 'UserType')
    if ($userTypeNames.ContainsKey($userType)) { $userType = $userTypeNames[$userType] }

    $results.Add([PSCustomObject]@{
            CreationDate = [datetime]$record.CreationDate
            UserIds      = [string]$record.UserIds
            Operations   = [string]$record.Operations
            RecordType   = [string]$record.RecordType
            Workload     = [string](Get-AuditField -AuditData $audit -Names 'Workload')
            ClientIP     = [string](Get-AuditField -AuditData $audit -Names 'ClientIP', 'ClientIPAddress', 'ActorIpAddress')
            ObjectId     = [string](Get-AuditField -AuditData $audit -Names 'ObjectId')
            ResultStatus = [string](Get-AuditField -AuditData $audit -Names 'ResultStatus')
            UserAgent    = [string]$userAgent
            UserType     = $userType
            Id           = [string](Get-AuditField -AuditData $audit -Names 'Id')
            AuditData    = [string]$record.AuditData
        })
}
$results = @($results | Sort-Object -Property CreationDate)

if ($results.Count -gt 0) {
    $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Warning 'The search returned no audit records for the given window and filters.'
}

Write-Host ''
Write-Host 'Unified audit log search summary' -ForegroundColor Cyan
Write-Host ('  Window         : {0:yyyy-MM-dd HH:mm} to {1:yyyy-MM-dd HH:mm} in {2} slice(s) of {3} h' -f $StartDate, $EndDate, $totalSlices, $IntervalHours)
Write-Host ('  Records        : {0}' -f $results.Count)
if ($results.Count -gt 0) {
    Write-Host '  Top operations :'
    foreach ($group in ($results | Group-Object -Property Operations | Sort-Object -Property Count -Descending | Select-Object -First 10)) {
        Write-Host ('    {0,7}  {1}' -f $group.Count, $group.Name)
    }
    Write-Host '  By workload    :'
    foreach ($group in ($results | Group-Object -Property Workload | Sort-Object -Property Count -Descending)) {
        Write-Host ('    {0,7}  {1}' -f $group.Count, $(if ([string]::IsNullOrWhiteSpace($group.Name)) { '(not in AuditData)' } else { $group.Name }))
    }
    Write-Host ('  Report         : {0}' -f $OutputPath)
}

if ($PassThru) {
    $results
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.