internal/functions/Get-SimpleUnifiedAuditLog.ps1
function Get-SimpleUnifiedAuditLog { <# .SYNOPSIS Flattens nested Microsoft 365 Unified Audit Log records into a simplified format. .DESCRIPTION This function processes Microsoft 365 Unified Audit Log records by converting nested JSON data (stored in the AuditData property) into a flat structure suitable for analysis and export. It handles complex nested objects, arrays, and special cases like parameter collections. The function: - Preserves base record properties - Flattens nested JSON structures - Provides special handling for Parameters collections - Creates human-readable command reconstructions - Supports type preservation for data analysis .PARAMETER Record A PowerShell object representing a unified audit log record. Typically, this is the output from Search-UnifiedAuditLog and should contain both base properties and an AuditData property containing a JSON string of additional audit information. .PARAMETER PreserveTypes When specified, maintains the original data types of values instead of converting them to strings. This is useful when the output will be used for further PowerShell processing rather than export to CSV/JSON. .EXAMPLE $auditLogs = Search-UnifiedAuditLog -StartDate $startDate -EndDate $endDate -RecordType ExchangeAdmin $auditLogs | Get-SimpleUnifiedAuditLog | Export-Csv -Path "AuditLogs.csv" -NoTypeInformation Processes Exchange admin audit logs and exports them to CSV with all nested properties flattened. .EXAMPLE $userChanges = Search-UnifiedAuditLog -UserIds user@domain.com -Operations "Add-*" $userChanges | Get-SimpleUnifiedAuditLog -PreserveTypes | Where-Object { $_.ResultStatus -eq $true } | Select-Object CreationTime, Operation, FullCommand Gets all "Add" operations for a specific user, preserves data types, filters for successful operations, and selects specific columns. .OUTPUTS Collection of PSCustomObjects with flattened properties from both the base record and AuditData. Properties include: - All base record properties (RecordType, CreationDate, etc.) - Flattened nested objects with property names using dot notation - Individual parameters as Param_* properties - ParameterString containing all parameters in a readable format - FullCommand showing reconstructed PowerShell command (when applicable) .NOTES Author: Jonathan Butler Version: 2.0 Development Date: December 2024 The function is designed to handle any RecordType from the Unified Audit Log and will automatically adapt to changes in the audit log schema. Special handling is implemented for common patterns like Parameters collections while maintaining flexibility for other nested structures. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [PSObject]$Record, [Parameter(Mandatory = $false)] [switch]$PreserveTypes ) begin { # Collection to store processed results $Results = @() function ConvertTo-FlatObject { <# .SYNOPSIS Recursively flattens nested objects into a single-level hashtable. .DESCRIPTION Internal helper function that converts complex nested objects into a flat structure using dot notation for property names. Handles special cases like Parameters arrays and preserves type information when requested. #> param ( [Parameter(Mandatory = $true)] [PSObject]$InputObject, [Parameter(Mandatory = $false)] [string]$Prefix = "", [Parameter(Mandatory = $false)] [switch]$PreserveTypes ) # Initialize hashtable for flattened properties $flatProperties = @{} # Process each property of the input object foreach ($prop in $InputObject.PSObject.Properties) { # Build the property key name, incorporating prefix if provided $key = if ($Prefix) { "${Prefix}_$($prop.Name)" } else { $prop.Name } # Special handling for Parameters array - common in UAL records if ($prop.Name -eq 'Parameters' -and $prop.Value -is [Array]) { # Create human-readable parameter string $paramStrings = foreach ($param in $prop.Value) { "$($param.Name)=$($param.Value)" } $flatProperties['ParameterString'] = $paramStrings -join ' | ' # Create individual parameter properties foreach ($param in $prop.Value) { $paramKey = "Param_$($param.Name)" $flatProperties[$paramKey] = $param.Value } # Reconstruct full command if Operation property exists if ($InputObject.Operation) { $paramStrings = foreach ($param in $prop.Value) { # Format parameter values based on content $value = switch -Regex ($param.Value) { '\s' { "'$($param.Value)'" } # Quote values containing spaces '^True$|^False$' { "`$$($param.Value.ToLower())" } # Format booleans ';' { "'$($param.Value)'" } # Quote values containing semicolons default { $param.Value } } "-$($param.Name) $value" } $flatProperties['FullCommand'] = "$($InputObject.Operation) $($paramStrings -join ' ')" } continue } # Handle different value types switch ($prop.Value) { # Recursively process nested hashtables { $_ -is [System.Collections.IDictionary] } { $nestedObject = ConvertTo-FlatObject -InputObject $_ -Prefix $key -PreserveTypes:$PreserveTypes $flatProperties += $nestedObject } # Process arrays (excluding Parameters which was handled above) { $_ -is [System.Collections.IList] -and $prop.Name -ne 'Parameters' } { if ($_.Count -gt 0) { if ($_[0] -is [PSObject]) { # Handle array of objects for ($i = 0; $i -lt $_.Count; $i++) { $nestedObject = ConvertTo-FlatObject -InputObject $_[$i] -Prefix "${key}_${i}" -PreserveTypes:$PreserveTypes $flatProperties += $nestedObject } } else { # Handle array of simple values $flatProperties[$key] = $_ -join "|" } } else { # Handle empty arrays $flatProperties[$key] = [string]::Empty } } # Recursively process nested objects { $_ -is [PSObject] } { $nestedObject = ConvertTo-FlatObject -InputObject $_ -Prefix $key -PreserveTypes:$PreserveTypes $flatProperties += $nestedObject } # Handle simple values default { if ($PreserveTypes) { # Keep original type if PreserveTypes is specified $flatProperties[$key] = $_ } else { # Convert values to appropriate types $flatProperties[$key] = switch ($_) { { $_ -is [datetime] } { $_ } { $_ -is [bool] } { $_ } { $_ -is [int] } { $_ } { $_ -is [long] } { $_ } { $_ -is [decimal] } { $_ } { $_ -is [double] } { $_ } default { [string]$_ } } } } } } return $flatProperties } } process { try { # Extract base properties excluding AuditData $baseProperties = $Record | Select-Object * -ExcludeProperty AuditData # Process AuditData if present $auditData = $Record.AuditData | ConvertFrom-Json if ($auditData) { # Flatten the audit data $flatAuditData = ConvertTo-FlatObject -InputObject $auditData -PreserveTypes:$PreserveTypes # Combine base properties with flattened audit data $combinedProperties = @{} $baseProperties.PSObject.Properties | ForEach-Object { $combinedProperties[$_.Name] = $_.Value } $flatAuditData.GetEnumerator() | ForEach-Object { $combinedProperties[$_.Key] = $_.Value } # Create and store the result $Results += [PSCustomObject]$combinedProperties } } catch { # Handle and log any processing errors Write-Warning "Error processing record: $_" $errorProperties = @{ RecordType = $Record.RecordType CreationDate = Get-Date Error = $_.Exception.Message Record = $Record } $Results += [PSCustomObject]$errorProperties } } end { # Define the ordered common schema properties $orderedProperties = @( 'CreationTime', 'Workload', 'RecordType', 'Operation', 'ResultStatus', 'ClientIP', 'UserId', 'Id', 'OrganizationId', 'UserType', 'UserKey', 'ObjectId', 'Scope', 'AppAccessContext' ) # Process each result to ensure proper property ordering $orderedResults = $Results | ForEach-Object { $orderedObject = [ordered]@{} # Add ordered common schema properties first foreach ($prop in $orderedProperties) { if ($_.PSObject.Properties.Name -contains $prop) { $orderedObject[$prop] = $_.$prop } } # Add ParameterString if it exists if ($_.PSObject.Properties.Name -contains 'ParameterString') { $orderedObject['ParameterString'] = $_.ParameterString # Add all Param_* properties immediately after ParameterString $_.PSObject.Properties | Where-Object { $_.Name -like 'Param_*' } | Sort-Object Name | ForEach-Object { $orderedObject[$_.Name] = $_.Value } } # Add all remaining properties that aren't already added $_.PSObject.Properties | Where-Object { $_.Name -notin $orderedProperties -and $_.Name -ne 'ParameterString' -and $_.Name -notlike 'Param_*' } | ForEach-Object { $orderedObject[$_.Name] = $_.Value } # Return the ordered object [PSCustomObject]$orderedObject } # Return all processed results with ordered properties $orderedResults } } |