365 Audit Log Parser Powershell Script

Export, configure, and view audit log records | Microsoft Learn

<#PSScriptInfo
 
.VERSION 1.4-fixed
 
.GUID 2082a2b3-155c-42e5-b3f6-c67434a0a3e1
 
.AUTHOR [email protected] (fixed by PA)
 
.TAGS O365 UnifiedAuditlog Auditlog parser
 
.RELEASENOTES
Fixed encoding issues with special characters, smart quotes, colon-splitting for URLs, and uninitialized variable.
 
#> 

<#
 
.DESCRIPTION
  
O365 auditlog (Unified log) parser.
 
Applies to logs downloaded from https://protection.office.com - Search & investigation - Audit log search - Download - .csv
 
Downloaded log has 4 colums:
CreationDate | UserIds | Operations | Auditdata
Problem: the most important one (Auditdata) is string mess where data is delimited with ; , and [] and you can't really import it to excel to filter reasonably for examing.
Also problem: different services log more or less data so no fixed amount of columns
 
This parser will modify the Auditdata column, creates a table and exports the parsered csv file (to be imported to excel).
More comments inside the script.
 
 
#> 

Param(
    [Parameter(Mandatory=$false)]
    [string]$FilePath
)

# O365 auditlog (Unified log) parser
# Applies to logs downloaded from https://protection.office.com - Search & investigation - Audit log search - Download .csv

# Downloaded log has 4 colums
# CreationDate | UserIds | Operations | Auditdata
# Problem: the most important one (Auditdata) is "string mess" where data is delimited with ; , and [] and you can't really import it to excel to filter reasonably.
# Also problem: different services log more or less data so no fixed amount of columns

# Solution: delimiter to be used while importing to Excel seems to be "," BUT NOT INSIDE brackets [{ }] so we change all "," --> || as delimiter inside brackets []
# Then we can split and construct a table in a format where it is csv exportable


# Columns for the result table
# Bad thing is that columns has to be known beforehand.
# https://support.office.com/en-us/article/detailed-properties-in-the-office-365-audit-log-ce004100-9e7f-443e-942b-9b04098fcfc3

$columnheaders = `
"CreationDate",`
"UserIds",`
"Operations",`
"Actor",`
"ActorContextId",`
"ActorIpAddress",`
"AddOnName",`
"AddOnType",`
"AppAccessContext",`
"ApplicationDisplayName",`
"ApplicationId",`
"AuthenticationType",`
"AzureActiveDirectoryEventType",`
"BrowserName",`
"BrowserVersion",`
"ChannelGuid",`
"ChannelName",`
"Client",`
"ClientInfoString",`
"ClientIP",`
"ClientIPAddress",`
"CorrelationId",`
"CreationTime",`
"CustomUniqueId",`
"DestinationFileExtension",`
"DestinationFileName",`
"DestinationRelativeUrl",`
"DeviceDisplayName",`
"EventData",`
"EventSource",`
"ExternalAccess",`
"ExtendedProperties",`
"GeoLocation",`
"HighPriorityMediaProcessing",`
"ID",`
"InternalLogonType",`
"InterSystemsId",`
"IntraSystemId",`
"IsManagedDevice",`
"ItemType",`
"ListBaseType",`
"ListId",`
"ListItemUniqueId",`
"ListServerTemplate",`
"LoginStatus",`
"LogonError",`
"LogonType",`
"MailboxGuid",`
"MailboxOwnerUPN",`
"Members",`
"ModifiedProperties (Name, NewValue, OldValue)",`
"ObjectID",`
"Operation",`
"OrganizationID",`
"OrganizationName",`
"OriginatingServer",`
"Path",`
"Parameters",`
"Platform",`
"RecordType",`
"ResultStatus",`
"SecurityComplianceCenterEventType",`
"SharingType",`
"Site",`
"SiteUrl",`
"SourceFileExtension",`
"SourceFileName",`
"SourceRelativeUrl",`
"Subject",`
"TabType",`
"Target",`
"TargetContextId",`
"TargetUserOrGroupName",`
"TargetUserOrGroupType",`
"TeamGuid",`
"TeamName",`
"UniqueSharingId",`
"UserAgent",`
"UserDomain",`
"UserID",`
"UserKey",`
"UserSharedWith",`
"UserType",`
"Version",`
"WebId",`
"Workload",`
"Miscellaneous"

############# Main script

if (-not $FilePath) {
    # No path provided - ask the user to type/paste it
    Write-Host "No -FilePath parameter provided." -ForegroundColor Yellow
    Write-Host "Tip: You can run this as:  .\AuditlogParser_Fixed.ps1 -FilePath 'C:\path\to\file.csv'" -ForegroundColor Yellow
    Write-Host ""
    $FilePath = Read-Host "Enter the full path to the CSV audit log file"
}

if (-not (Test-Path $FilePath)) {
    Write-Host "ERROR: File not found: $FilePath" -ForegroundColor Red
    exit
}

$logsource = $FilePath
Write-Host "Using file: $logsource" -ForegroundColor Green
$logdirectory = Split-Path -Parent $logsource
$exported_log = $logsource -replace ".csv","_parsed.csv"

#Progressbar Start time
$PBStartTime = Get-Date

$log = Import-Csv -Path $logsource

# Use || as replacement char for commas inside brackets [{ }]
# This avoids encoding issues with special characters
$ErotinChar = "||"

# Check the log doesn't already contain our delimiter
$logAsString = $log | Out-String
if ($logAsString.Contains("||")) {
    $ErotinChar = "^|^"
}

#total amount of rows in the log
$rivienmaara = $log.count
$rivilaskuri = 0

#create table to be populated, if exists then remove
$tabName = "O365LogTable"

if (Get-Variable -Name table -ErrorAction SilentlyContinue)
{
    Remove-Variable table
}
$table = New-Object system.Data.DataTable "$tabName"


# Build table columns
$columnloop = 0
$cols = @()
$colheaders = @()
do
{
    $cols += $columnheaders[$columnloop]
    $colheaders += New-Object system.Data.DataColumn $cols[$columnloop],([string])

    #Add the Columns
    $table.columns.add($colheaders[$columnloop])

    $columnloop++
}
until ($columnloop -gt ($columnheaders.count)-1)



# Begin to parse line by line

do
{
    $string = $log[$rivilaskuri]

    $parseredstring = $string

    #trimming brackets
    $parseredstring.Auditdata = $parseredstring.Auditdata.Trim("{","}")

    $parseredstringaudit = $parseredstring.AuditData
    
    # Initialise $parsered in case no regex matches are found
    $parsered = $parseredstringaudit
    
    #NonGreedy, fetch only [{ }] and all of them to regex-groups
    $AllMatches = $parseredstringaudit | Select-String -Pattern "\[{.*?\}]" -AllMatches | foreach {$_.Matches}
   
    $matchcount = $AllMatches.Count
   
    if($AllMatches.Success)            
    {            
            
        #if only one hit, then don't iterate by items

        if($matchcount -eq 1){

            $old = $AllMatches.Value
            $new = $old -replace ",",$ErotinChar
   
            $parseredstringaudit = $parseredstringaudit.Replace($old,$new)
            $parsered = $parseredstringaudit

        }
        else
        {
            $matchloop = 0

           
            #loop line with regex-hits and replace comma (,) --> || ONLY inside [{ }]
            do
            {
            
                $old = $AllMatches[$matchloop].Value
                $new = $old -replace ",",$ErotinChar
   
                $parseredstringaudit = $parseredstringaudit.Replace($old,$new)
                $parsered = $parseredstringaudit

                $matchloop++
            }
            until ($matchloop -gt $matchcount-1)
         }
     }

 
    ######## Done replacing between [{ }]

    #after that we still have to look if there is ", " (<= ,<empty>) in the line
    
    #NonGreedy, fetch only ", " and all of them to regex-groups
    $AllMatches = $parseredstringaudit | Select-String -Pattern "\, " -AllMatches | foreach {$_.Matches}
    $matchcount = $AllMatches.Count

    if($AllMatches.Success)            
    {                    
        #if only one hit, then don't iterate by items

        if($matchcount -eq 1){

            $old = $AllMatches.Value
            $new = $old -replace ",",$ErotinChar
   
            $parseredstringaudit = $parseredstringaudit.Replace($old,$new)
            $parsered = $parseredstringaudit

        }
        else
        {
            $matchloop = 0

           
            #loop line with regex-hits and replace comma (,) --> || ONLY inside ", "
            do
            {
            
                $old = $AllMatches[$matchloop].Value
                $new = $old -replace ",",$ErotinChar
   
                $parseredstringaudit = $parseredstringaudit.Replace($old,$new)
                $parsered = $parseredstringaudit

                $matchloop++
            }
            until ($matchloop -gt $matchcount-1)
         }
     }

 
        ######## Done ", " -replacements
        
        # and now we are able to split

        $parseredsplitted = $parsered.Split(",")
        $parseredsplitcount = $parseredsplitted.Count

        $splitloop = 0

        #Create a row to table
        $row = $table.NewRow()
        
        #loop and get headers and values
        do
        {
    
        # FIX: Use IndexOf to find FIRST colon, but for values containing URLs (https:// etc.)
        # we need to split on the first colon only, so the right part keeps the rest intact.
        $currentField = $parseredsplitted[$splitloop]
        $pos = $currentField.IndexOf(":")
        
        if ($pos -lt 0) {
            # No colon found, skip this field
            $splitloop++
            continue
        }

        $leftPart = $currentField.Substring(0, $pos)
        # FIX: Use Substring(pos+1) to get EVERYTHING after the first colon
        # This preserves URLs like https://... and timestamps with colons
        $rightpart = $currentField.Substring($pos + 1)

        #trim quotes
        $leftPart = $leftPart.Trim('"')
        $rightpart = $rightpart.Trim('"')


        ##### populate the values based on headers
        ##### best part of this that you can populate table by pointing to header names.
        ##### we don't have to worry about missing column values in the log row

        #find matching header "number"
        $colnumber = $table.Columns.IndexOf($leftPart)
        #If there is no match then use Miscellaneous-column
        if ($colnumber -eq "-1")
        {
            $colnumber = $table.Columns.IndexOf("Miscellaneous")
            Write-Host "No column match for '$leftPart', using Miscellaneous, line: $rivilaskuri"
        }

        #Enter data in the row
        $row.($cols[$colnumber]) = $rightpart

        $splitloop++
        }
        until ($splitloop -gt $parseredsplitcount-1)
  

        #Let's add three first columns to row from the original log
        $row.($cols[0]) = $log[$rivilaskuri].CreationDate
        $row.($cols[1]) = $log[$rivilaskuri].UserIds
        $row.($cols[2]) = $log[$rivilaskuri].Operations

        #Add the populated row to the table
        $table.Rows.Add($row)

        #skip to next line in log
        $rivilaskuri++   
        
        #Show some progress info to user
         
        ## -- Calculate The Percentage Completed
        [Int]$Percentage = ($rivilaskuri/$rivienmaara)*100

        #calculate seconds
        $SecondsElapsed = ((Get-Date) - $PBStartTime).TotalSeconds
        $SecondsRemaining = ($SecondsElapsed / ($rivilaskuri/$rivienmaara)) - $SecondsElapsed

        #transform to more readable format
        $kulsek =  [timespan]::fromseconds($SecondsElapsed)
        $sekunnitkulunut = $kulsek.ToString("hh\:mm\:ss")

        $jalsek =  [timespan]::fromseconds($SecondsRemaining)
        $sekunnitjaljella = $jalsek.ToString("hh\:mm\:ss")

        Write-Progress -Activity "Processing" -Status "Processing: $rivilaskuri / $rivienmaara Elapsed Time: $sekunnitkulunut, Estimated time left: $sekunnitjaljella" -PercentComplete $Percentage
      
}
until ($rivilaskuri -gt $rivienmaara-1)

#close the bar
Write-Progress -Activity "Processing" -Status "Ready" -Completed

#Display the table
#$table | Format-Table


$table | Export-Csv -Path $exported_log -NoTypeInformation
Write-Host "`nOriginal log: $logsource" -ForegroundColor Yellow 
Write-Host "Parsed log: $exported_log`n" -ForegroundColor Yellow 


# Ask user if wants to open web-page where O365 detailed log is

Write-host "Would you like to open in browser detailed O365-log properties webpage (Microsoft's)?" -ForegroundColor Yellow 
    $Readhost = Read-Host " ( y / n ) " 
    Switch ($ReadHost) 
     { 
       Y {Write-host "Yes, opening"; Start-Process "https://support.office.com/en-us/article/detailed-properties-in-the-office-365-audit-log-ce004100-9e7f-443e-942b-9b04098fcfc3"} 
       N {Write-Host "No"; $PublishSettings=$false} 
       Default {Write-Host "Default, opening"; Start-Process "https://support.office.com/en-us/article/detailed-properties-in-the-office-365-audit-log-ce004100-9e7f-443e-942b-9b04098fcfc3"} 
     } 


# Ask user if wants to open Folder of Parsed-csv

Write-host "Would you like to open Folder of End-Product ie. Parsed-csv?" -ForegroundColor Yellow 
    $Readhost = Read-Host " ( y / n ) " 
    Switch ($ReadHost) 
     { 
       Y {Write-host "Yes, opening"; Invoke-Item $logdirectory} 
       N {Write-Host "No"; $PublishSettings=$false} 
       Default {Write-Host "Default, opening"; Invoke-Item $logdirectory} 
     }  
1 Star2 Stars3 Stars4 Stars5 Stars (No Ratings Yet)
Loading...