Automating with PowerShell: Storing Office 365 audit logs longer than 90 days

A friend of mine recently bumped into an issue; his client wanted to know when a specific user logged on for the last time. The problem was that he did not have the unified audit log enabled, but even if he did the time-span was too long. We got lucky at using just the mailbox audit log over the past days so he could help his clients. He still worried about the Unified Audit logs and only having 90 days of logging.

I’ve told him about a couple of SIEM products, including Azure Sentinel which is able to ingest logs from Office 365. The problem is that for SIEM tooling you require a lot of a setup and a team to keep everything running smoothly, next to that most SIEMs aren’t really focused on the MSP-workflow. It was a bit overkill for what he wanted too.

So first I helped him setup the unified audit log for all his clients, and afterwards shared a script with him to store the audit logs in a readable format, with both a .CSV file and a HTML representation.

You can run this script in an Azure Runbook, Function, or just scheduled on a secure workstation.

The script downloads the information over the previous 1.2 days. We do this to cover the gap just in case the script runs a bit longer than expected.

The Script

As always, we’re using the Secure Application model. The script creates the folder structure for you.

# Write to the Azure Functions log stream.
Write-Host "Start at $(get-date)"
##########################################
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'YoursecretySecret' | Convertto-SecureString -AsPlainText -Force
$TenantID = 'YourTenantID'
$RefreshToken = 'verylongtoken'
$ExchangeRefreshToken = 'anotherverylongtoken'
$UPN = "any-valid-partner-upn"
$outputfolder = "D:\home\site\wwwroot\reports"
$ModulePath = "D:\home\site\wwwroot\AuditLogRetrieval\Modules"
##########################################
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken
#Logged in. Moving on to creating folders and getting data.
$folderName = (Get-Date).tostring("yyyy-MM-dd")
New-item -Path $outputfolder -ItemType Directory -Name $folderName -Force
$customers = Get-AzureADContract -All:$true
foreach ($customer in $customers) {
    New-item "$($outputfolder)" -name $Customer.DisplayName -ItemType Directory -Force
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.CustomerContextId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    Import-PSSession $session -allowclobber -DisableNameChecking -CommandName "Search-unifiedAuditLog"

    $startDate = (Get-Date).AddDays( - '1.2')
    $endDate = (Get-Date)
    $Logs = @()
    Write-Host "Retrieving logs for $($customer.displayname)" -ForegroundColor Blue
    do {
        $logs += Search-unifiedAuditLog -SessionCommand ReturnLargeSet -SessionId $customer.displayname -ResultSize 5000 -StartDate $startDate -EndDate $endDate
        Write-Host "Retrieved $($logs.count) logs" -ForegroundColor Yellow
    }while ($Logs.count % 5000 -eq 0 -and $logs.count -ne 0)


    Write-Host "Finished Retrieving logs" -ForegroundColor Green
    $ObjLogs = foreach ($Log in $Logs) {
        $log.auditdata | convertfrom-json
    }


    $PreContent = @"
<H1> $($Customer.DisplayName) - Audit Log from $StartDate until $EndDate </H1><br>
 
<br> Please note that this log is not complete - It is a representation where fields have been selected that are most commonly filtered on. .<br>
To analyze the complete log for this day, please click here for the complete CSV file log: <a href="$($folderName).csv"/>CSV Logbook</a>
<br/>
<br/>
 
<input type="text" id="myInput" onkeyup="myFunction()" placeholder="Search...">
"@
    $head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>Audit Log Report</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
    #$ObjLogs
    $Logs | export-csv "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).csv" -NoTypeInformation
    $ObjLogs | Select-object CreationTime, UserID, Operation, ResultStatus, ClientIP, Workload, ClientInfoString | Convertto-html -head $head -PreContent $PreContent | out-file "$($outputfolder)\$($Customer.DisplayName)\$($FolderName).html"
  
    write-host "Generating root HTML file with all dates" -ForegroundColor Green
    $Items = get-childitem "$($outputfolder)\$($Customer.DisplayName)" -Filter "*.html"
    $tableheader = "<table><tr><th>Date</th><th>HTML Report</th><th>CSV Report</th></tr>" 
    $URLS = foreach ($item in $Items) {
        @"
        <tr><td>$($item.basename)</td><td><a href=`"$($item.basename).html`"/>HTML Report</a></td><td><a href=`"$($item.basename).CSV`"/>CSV Report</a></td></tr>
"@
    }

    $Head, $tableheader, $URLS | out-file "$($outputfolder)\$($Customer.DisplayName)\index.html"
}

write-host "Generating index file for all clients." -ForegroundColor Green
$tableheader = "<table><tr><th>Customer Name</th><th>Onmicrosoft domain</th><th>Reports</th></tr>" 
$customerhtml = foreach ($customer in $customers) {
    @"
    <tr><td>$($customer.DisplayName)</td><td>$($customer.DefaultDomainName)</td><td><a href=`"$($customer.DisplayName)\index.html`"/>Reports</a></td></tr>
"@

}

$Head, $tableheader, $customerhtml | out-file "$($outputfolder)\index.html"
write-host "Ended at $(Get-date)"

And that’s it! an easy way to store the log files for longer than 90 days, and not have the investment of a SIEM. I’d still suggest looking into a SIEM for the long run though. As always, Happy PowerShelling!

8 thoughts on “Automating with PowerShell: Storing Office 365 audit logs longer than 90 days

  1. Cody

    I am trying to run this script. I have other scripts using the secure app model running and I am using the same secure application for all scripts. This one fails for me. “Get-AzureADContract : Error occurred while executing GetContracts
    Code: Authorization_RequestDenied” I connected without the secure app model to Connect-azureAD and I am able to run Get-AzureADContract -All:$true,

    Any idea what I may have wrong here?

    Reply
  2. Leo Gonzaelz

    Hello. Nice Script. If I can get this to work I will be a hero. I’m getting the following errors:

    New-PartnerAccessToken : The term ‘New-PartnerAccessToken’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling
    of the name, or if a path was included, verify that the path is correct and try again.
    At line:14 char:18
    + $aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId …
    + ~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : ObjectNotFound: (New-PartnerAccessToken:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

    New-PartnerAccessToken : The term ‘New-PartnerAccessToken’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling
    of the name, or if a path was included, verify that the path is correct and try again.
    At line:15 char:15
    + $graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -C …
    + ~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : ObjectNotFound: (New-PartnerAccessToken:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

    Connect-AzureAD : Cannot validate argument on parameter ‘AadAccessToken’. The argument is null or empty. Provide an argument that is not null or empty, and then try
    the command again.
    At line:16 char:33
    + Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Connect-AzureAD], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.Open.Azure.AD.CommonLibrary.ConnectAzureAD

    If you can help me with this I would really appreciate it.

    Reply
  3. Leo Gonzalez

    Hello Kelvin. Thank you for your reply I installed the module and tried the script again. Here is what I’m getting now:

    PS C:\WINDOWS\system32> E:\Users\lgonzalez.SJGH\Desktop\Auditlogs.ps1
    Start at 08/13/2020 12:07:23
    New-PartnerAccessToken : Error: ClientId is not a Guid.
    At E:\Users\lgonzalez.SJGH\Desktop\Auditlogs.ps1:14 char:18
    + … raphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Cre …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : CloseError: (:) [New-PartnerAccessToken], MsalClientException
    + FullyQualifiedErrorId : Microsoft.Store.PartnerCenter.PowerShell.Commands.NewPartnerAccessToken

    New-PartnerAccessToken : Error: ClientId is not a Guid.
    At E:\Users\lgonzalez.SJGH\Desktop\Auditlogs.ps1:15 char:15
    + … raphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Cre …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : CloseError: (:) [New-PartnerAccessToken], MsalClientException
    + FullyQualifiedErrorId : Microsoft.Store.PartnerCenter.PowerShell.Commands.NewPartnerAccessToken

    Connect-AzureAD : Cannot validate argument on parameter ‘AadAccessToken’. The argument is null or empty. Provide an argument that is not null or empty, and then try
    the command again.
    At E:\Users\lgonzalez.SJGH\Desktop\Auditlogs.ps1:16 char:33
    + Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId …
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (:) [Connect-AzureAD], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.Open.Azure.AD.CommonLibrary.ConnectAzureAD

    Directory: D:\home\site\wwwroot\reports

    Mode LastWriteTime Length Name
    —- ————- —— —-
    d—– 8/13/2020 12:06 PM 2020-08-13
    Generating index file for all clients.
    Ended at 08/13/2020 12:07:24

    Reply
  4. Jimmy Sandison

    Hi Kelvin,

    This is a very nice script and I am looking to implement this in my environment but we are not a Microsoft Partner. Do you happen to have this script in a non-partner format?

    Thank you,

    Jimmy

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.