Hawk.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\Hawk.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName Hawk.Import.DoDotSource -Fallback $false if ($Hawk_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName Hawk.Import.IndividualFiles -Fallback $false if ($Hawk_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'Hawk' -Language 'en-US' <# .SYNOPSIS Add objects to the hawk app data .DESCRIPTION Add objects to the hawk app data .PARAMETER Name Name variable .PARAMETER Value Value of of retieved data .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Add-HawkAppData { param ( [string]$Name, [string]$Value ) Out-LogFile ("Adding " + $value + " to " + $Name + " in HawkAppData") -Action # Test if our HawkAppData variable exists if ([bool](get-variable HawkAppData -ErrorAction SilentlyContinue)) { $global:HawkAppData | Add-Member -MemberType NoteProperty -Name $Name -Value $Value } else { $global:HawkAppData = New-Object -TypeName PSObject $global:HawkAppData | Add-Member -MemberType NoteProperty -Name $Name -Value $Value } # make sure we then write that out to the appdata storage Out-HawkAppData } <# .SYNOPSIS Compress all hawk data for upload Compresses all files located in the $Hawk.FilePath folder .DESCRIPTION Compress all hawk data for upload Compresses all files located in the $Hawk.FilePath folder .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Compress-HawkData { Out-LogFile ("Compressing all data in " + $Hawk.FilePath + " for Upload") # Make sure we don't already have a zip file if ($null -eq (Get-ChildItem *.zip -Path $Hawk.filepath)) { } else { Out-LogFile ("Removing existing zip file(s) from " + $Hawk.filepath) $allfiles = Get-ChildItem *.zip -Path $Hawk.FilePath # Remove the existing zip files foreach ($file in $allfiles) { $Error.Clear() Remove-Item $File.FullName -Confirm:$false -ErrorAction SilentlyContinue # Make sure we didn't throw an error when we tried to remove them if ($Error.Count -gt 0) { Out-LogFile "Unable to remove existing zip files from " + $Hawk.filepath + " please remove them manually" Write-Error -Message ("Unable to remove existing zip files from " + $Hawk.filepath + " please remove them manually") -ErrorAction Stop } else { } } } # Get all of the files in the output directory #[array]$allfiles = Get-ChildItem -Path $Hawk.filepath -Recurse #Out-LogFile ("Found " + $allfiles.count + " files to add to zip") # create the zip file name [string]$zipname = "Hawk_" + (Split-path $Hawk.filepath -Leaf) + ".zip" [string]$zipfullpath = Join-Path $env:TEMP $zipname Out-LogFile ("Creating temporary zip file " + $zipfullpath) # Load the zip assembly Add-Type -Assembly System.IO.Compression.FileSystem # Create the zip file from the current hawk file directory [System.IO.Compression.ZipFile]::CreateFromDirectory($Hawk.filepath, $zipfullpath) # Move the item from the temp directory to the full filepath Out-LogFile ("Moving file to the " + $hawk.filepath + " directory") Move-Item $zipfullpath (Join-Path $Hawk.filepath $zipname) } Function Convert-HawkDaysToDate { <# .SYNOPSIS Converts the DaysToLookBack parameter into a StartDate and EndDate for use in Hawk investigations. .DESCRIPTION This function takes the number of days to look back from the current date and calculates the corresponding StartDate and EndDate in UTC format. The StartDate is calculated by subtracting the specified number of days from the current date, and the EndDate is set to one day in the future (to include the entire current day). .PARAMETER DaysToLookBack The number of days to look back from the current date. Must be between 1 and 365. .OUTPUTS A PSCustomObject with two properties: - StartDate: The calculated start date in UTC format. - EndDate: The calculated end date in UTC format (one day in the future). .EXAMPLE Convert-HawkDaysToDates -DaysToLookBack 30 Returns a StartDate of 30 days ago and an EndDate of tomorrow in UTC format. .NOTES This function ensures that the date range does not exceed 365 days and that the dates are properly formatted for use with Hawk investigation functions. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [int]$DaysToLookBack ) # Calculate the dates $startDate = (Get-Date).ToUniversalTime().AddDays(-$DaysToLookBack).Date # EndDate should be midnight of next day $endDate = (Get-Date).ToUniversalTime().Date.AddDays(1) # Return the dates as a PSCustomObject [PSCustomObject]@{ StartDate = $startDate EndDate = $endDate } } <# .SYNOPSIS Convert a reportxml to html .DESCRIPTION Convert a reportxml to html .PARAMETER xml XML format .PARAMETER xsl XLS format .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Convert-ReportToHTML { param ( [Parameter(Mandatory = $true)] $Xml, [Parameter(Mandatory = $true)] $Xsl ) begin { # Make sure that the files are there if (!(test-path $Xml)) { Write-Error "XML File not found for conversion" -ErrorAction Stop } if (!(test-path $Xsl)) { Write-Error "XSL File not found for Conversion" -ErrorAction Stop } } process { # Create the output file name $OutputFile = Join-Path (Split-path $xml) ((split-path $xml -Leaf).split(".")[0] + ".html") # Run the transform on the XML and produce the HTML $xslt = New-Object System.Xml.Xsl.XslCompiledTransform; $xslt.Load($xsl); $xslt.Transform($xml, $OutputFile); } end { } } function Convert-HawkRiskData { <# .SYNOPSIS Parses and flattens risk detection additional information data. .DESCRIPTION Internal helper function that processes additional information from risk detection data, converting nested JSON structures into a flat format suitable for Get-SimpleUnifiedAuditLog processing. Handles common risk data fields including: - riskReasons (array) - userAgent - alertUrl - mitreTechniques .PARAMETER RiskData Risk detection or user data containing AdditionalInfo property with JSON data. .EXAMPLE $parsedData = Convert-HawkRiskData -RiskData $riskDetections $parsedData | Get-SimpleUnifiedAuditLog Parses risk detection data before passing to Get-SimpleUnifiedAuditLog. .NOTES Internal function for use by Hawk risk analysis functions. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [object[]]$RiskData ) begin { $processedData = @() } process { foreach ($record in $RiskData) { # Create copy of original record excluding AdditionalInfo $processedRecord = $record | Select-Object * -ExcludeProperty AdditionalInfo if ($record.AdditionalInfo) { try { # Parse JSON if string, otherwise use as-is if ($record.AdditionalInfo -is [string]) { $additionalInfo = $record.AdditionalInfo | ConvertFrom-Json } else { $additionalInfo = $record.AdditionalInfo } # Convert each key-value pair to a property foreach ($item in $additionalInfo) { $propertyName = "AdditionalInfo_$($item.Key)" if ($item.Value -is [array]) { # Join array values with pipe delimiter $propertyValue = $item.Value -join '|' } else { $propertyValue = $item.Value } # Add as new property to processed record Add-Member -InputObject $processedRecord -MemberType NoteProperty -Name $propertyName -Value $propertyValue -Force } } catch { Write-Warning "Error processing AdditionalInfo for record: $_" } } $processedData += $processedRecord } } end { return $processedData } } Function Get-AllUnifiedAuditLogEntry { <# .SYNOPSIS Make sure we get back all of the unified audit log results for the search we are doing .DESCRIPTION Make sure we get back all of the unified audit log results for the search we are doing .PARAMETER UnifiedSearch The search parameters .PARAMETER StartDate The start date provided by user during Hawk Object Initialization .PARAMETER EndDate The end date provide by the user during Hawk Object Initialization .EXAMPLE Get-AllUnifiedAuditLogEntry Gets all unified auditlog entries .NOTES General notes #> param ( [Parameter(Mandatory = $true)] [string]$UnifiedSearch, [datetime]$StartDate = $Hawk.StartDate, [datetime]$EndDate = $Hawk.EndDate ) # Validate the incoming search command if ($UnifiedSearch -match "-StartDate|-EndDate|-SessionCommand|-ResultSize|-SessionId") { Out-LogFile "Do not include any of the following in the Search Command" -isError Out-LogFile "-StartDate, -EndDate, -SessionCommand, -ResultSize, -SessionID" -isError Write-Error -Message "Unable to process search command, switch in UnifiedSearch that is handled by this cmdlet specified" -ErrorAction Stop } # build our search command to execute $cmd = $UnifiedSearch + " -StartDate `'" + (get-date ($StartDate) -UFormat %m/%d/%Y) + "`' -EndDate `'" + (get-date ($endDate) -UFormat %m/%d/%Y) + "`' -SessionCommand ReturnLargeSet -resultsize 5000 -sessionid " + (Get-Date -UFormat %H%M%S) Out-LogFile ("Running Unified Audit Log Search") -Action Out-Logfile $cmd -NoDisplay # Run the initial command $Output = $null # $Output = New-Object System.Collections.ArrayList # Setup our run variable $Run = $true # Convert the command string into a scriptblock to avoid Invoke-Expression $searchScript = [ScriptBlock]::Create($cmd) # Since we have more than 1k results we need to keep returning results until we have them all while ($Run) { $Output += & $searchScript # Check for null results if so warn and stop if ($null -eq $Output) { Out-LogFile ("Unified Audit log returned no results.") -Information $Run = $false } # Else continue else { # Sort our result set to make sure the higest number is in the last position $Output = $Output | Sort-Object -Property ResultIndex # if total result count returned is 0 then we should warn and stop if ($Output[-1].ResultCount -eq 0) { Out-LogFile ("Returned Result count was 0") -Information $Run = $false } # if our resultindex = our resultcount then we have everything and should stop elseif ($Output[-1].Resultindex -ge $Output[-1].ResultCount) { Out-LogFile ("Retrieved all results.") -Information $Run = $false } # Output the current progress Out-LogFile ("Retrieved:" + $Output[-1].ResultIndex.tostring().PadRight(5, " ") + " Total: " + $Output[-1].ResultCount) -Information } } # Convert our list to an array and return it [array]$Output = $Output return $Output } Function Get-AzureADPSPermission { <# .SYNOPSIS Lists delegated permissions (OAuth2PermissionGrants) and application permissions (AppRoleAssignments). .DESCRIPTION Lists delegated permissions (OAuth2PermissionGrants) and application permissions (AppRoleAssignments) using Microsoft Graph API. This function retrieves and formats permission information for analysis of application and delegated permissions in your tenant. .PARAMETER DelegatedPermissions If set, will return delegated permissions. If neither this switch nor the ApplicationPermissions switch is set, both application and delegated permissions will be returned. .PARAMETER ApplicationPermissions If set, will return application permissions. If neither this switch nor the DelegatedPermissions switch is set, both application and delegated permissions will be returned. .PARAMETER UserProperties The list of properties of user objects to include in the output. Defaults to DisplayName only. .PARAMETER ServicePrincipalProperties The list of properties of service principals (i.e. apps) to include in the output. Defaults to DisplayName only. .PARAMETER ShowProgress Whether or not to display a progress bar when retrieving application permissions (which could take some time). .PARAMETER PrecacheSize The number of users to pre-load into a cache. For tenants with over a thousand users, increasing this may improve performance of the script. .EXAMPLE PS C:\> Get-AzureADPSPermission | Export-Csv -Path "permissions.csv" -NoTypeInformation Generates a CSV report of all permissions granted to all apps. .EXAMPLE PS C:\> Get-AzureADPSPermission -ApplicationPermissions -ShowProgress | Where-Object { $_.Permission -eq "Directory.Read.All" } Get all apps which have application permissions for Directory.Read.All. .EXAMPLE PS C:\> Get-AzureADPSPermission -UserProperties @("DisplayName", "UserPrincipalName", "Mail") -ServicePrincipalProperties @("DisplayName", "AppId") Gets all permissions granted to all apps and includes additional properties for users and service principals. .NOTES This function requires Microsoft.Graph PowerShell module and appropriate permissions: - Application.Read.All - Directory.Read.All #> [CmdletBinding()] param( [switch] $DelegatedPermissions, [switch] $ApplicationPermissions, [string[]] $UserProperties = @("DisplayName"), [string[]] $ServicePrincipalProperties = @("DisplayName"), [switch] $ShowProgress, [System.Int32] $PrecacheSize = 999 ) # Verify Graph connection try { $tenant_details = Get-MgOrganization } catch { throw "You must call Connect-MgGraph before running this script." } Write-Verbose ("TenantId: {0}" -f $tenant_details.Id) # Cache objects $script:ObjectByObjectId = @{} $script:ObjectByObjectType = @{ 'ServicePrincipal' = @{} 'User' = @{} } function CacheObject ($Object, $Type) { if ($Object) { $script:ObjectByObjectType[$Type][$Object.Id] = $Object $script:ObjectByObjectId[$Object.Id] = $Object } } function GetObjectByObjectId ($ObjectId) { if (-not $script:ObjectByObjectId.ContainsKey($ObjectId)) { Write-Verbose ("Querying Graph API for object '{0}'" -f $ObjectId) try { $object = Get-MgDirectoryObject -DirectoryObjectId $ObjectId # Determine type from OdataType $type = $object.AdditionalProperties.'@odata.type'.Split('.')[-1] CacheObject -Object $object -Type $type } catch { Write-Verbose "Object not found." } } return $script:ObjectByObjectId[$ObjectId] } # Cache all service principals Write-Verbose "Retrieving all ServicePrincipal objects..." $servicePrincipals = Get-MgServicePrincipal -All foreach($sp in $servicePrincipals) { CacheObject -Object $sp -Type 'ServicePrincipal' } $servicePrincipalCount = $servicePrincipals.Count # Cache users Write-Verbose ("Retrieving up to {0} User objects..." -f $PrecacheSize) $users = Get-MgUser -Top $PrecacheSize foreach($user in $users) { CacheObject -Object $user -Type 'User' } if ($DelegatedPermissions -or (-not ($DelegatedPermissions -or $ApplicationPermissions))) { Write-Verbose "Retrieving OAuth2PermissionGrants..." $oauth2Grants = Get-MgOAuth2PermissionGrant -All foreach ($grant in $oauth2Grants) { if ($grant.Scope) { $grant.Scope.Split(" ") | Where-Object { $_ } | ForEach-Object { $scope = $_ $grantDetails = [ordered]@{ "PermissionType" = "Delegated" "ClientObjectId" = $grant.ClientId "ResourceObjectId" = $grant.ResourceId "Permission" = $scope "ConsentType" = $grant.ConsentType "PrincipalObjectId" = $grant.PrincipalId } # Add service principal properties if ($ServicePrincipalProperties.Count -gt 0) { $client = $script:ObjectByObjectId[$grant.ClientId] $resource = $script:ObjectByObjectId[$grant.ResourceId] $insertAtClient = 2 $insertAtResource = 3 foreach ($propertyName in $ServicePrincipalProperties) { $grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName) $insertAtResource++ $grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName) $insertAtResource++ } } # Add user properties if ($UserProperties.Count -gt 0) { $principal = if ($grant.PrincipalId) { $script:ObjectByObjectId[$grant.PrincipalId] } else { @{} } foreach ($propertyName in $UserProperties) { $grantDetails["Principal$propertyName"] = $principal.$propertyName } } New-Object PSObject -Property $grantDetails } } } } if ($ApplicationPermissions -or (-not ($DelegatedPermissions -or $ApplicationPermissions))) { Write-Verbose "Retrieving AppRoleAssignments..." $i = 0 foreach ($sp in $servicePrincipals) { if ($ShowProgress) { Write-Progress -Activity "Retrieving application permissions..." ` -Status ("Checked {0}/{1} apps" -f $i++, $servicePrincipalCount) ` -PercentComplete (($i / $servicePrincipalCount) * 100) } $appRoleAssignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All foreach ($assignment in $appRoleAssignments) { if ($assignment.PrincipalType -eq "ServicePrincipal") { $resource = $script:ObjectByObjectId[$assignment.ResourceId] $appRole = $resource.AppRoles | Where-Object { $_.Id -eq $assignment.AppRoleId } $grantDetails = [ordered]@{ "PermissionType" = "Application" "ClientObjectId" = $assignment.PrincipalId "ResourceObjectId" = $assignment.ResourceId "Permission" = $appRole.Value } if ($ServicePrincipalProperties.Count -gt 0) { $client = $script:ObjectByObjectId[$assignment.PrincipalId] $insertAtClient = 2 $insertAtResource = 3 foreach ($propertyName in $ServicePrincipalProperties) { $grantDetails.Insert($insertAtClient++, "Client$propertyName", $client.$propertyName) $insertAtResource++ $grantDetails.Insert($insertAtResource, "Resource$propertyName", $resource.$propertyName) $insertAtResource++ } } New-Object PSObject -Property $grantDetails } } } if ($ShowProgress) { Write-Progress -Completed -Activity "Retrieving application permissions..." } } } Function Get-HawkUserPath { <# .SYNOPSIS Gets the output folder path for a specific user in Hawk .DESCRIPTION Creates and returns the full path to a user's output folder within the Hawk file structure. Creates the folder if it doesn't exist. .PARAMETER User The UserPrincipalName of the user to create/get path for .EXAMPLE Get-HawkUserPath -User "user@contoso.com" Returns the full path to the user's output folder and creates it if it doesn't exist .OUTPUTS System.String Returns the full path to the user's output folder .NOTES Internal function used by Hawk cmdlets to manage user-specific output folders #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$User ) # Check if Hawk global object exists if ([string]::IsNullOrEmpty($Hawk.FilePath)) { Initialize-HawkGlobalObject } # Join the Hawk filepath with the user's UPN for the output folder $userPath = Join-Path -Path $Hawk.FilePath -ChildPath $User # Create directory if it doesn't exist if (-not (Test-Path -Path $userPath)) { Out-LogFile "Making output directory for user $userPath" New-Item -Path $userPath -ItemType Directory -Force | Out-Null } return $userPath } <# .SYNOPSIS Get the Location of an IP using the freegeoip.net rest API .DESCRIPTION Get the Location of an IP using the freegeoip.net rest API .PARAMETER IPAddress IP address of geolocation .EXAMPLE Get-IPGeolocation Gets all IP Geolocation data of IPs that recieved .NOTES General notes #> Function Get-IPGeolocation { Param ( [Parameter(Mandatory = $true)] $IPAddress ) # If we don't have a HawkAppData variable then we need to read it in if (!([bool](get-variable HawkAppData -erroraction silentlycontinue))) { Read-HawkAppData } # if there is no value of access_key then we need to get it from the user if ($null -eq $HawkAppData.access_key) { Out-LogFile "IpStack.com now requires an API access key to gather GeoIP information from their API.`nPlease get a Free access key from https://ipstack.com/ and provide it below." -Information # get the access key from the user # get the access key from the user Out-LogFile "ipstack.com accesskey" -isPrompt -NoNewLine $Accesskey = (Read-Host).Trim() # add the access key to the appdata file Add-HawkAppData -name access_key -Value $Accesskey } else { $Accesskey = $HawkAppData.access_key } # Check the global IP cache and see if we already have the IP there if ($IPLocationCache.ip -contains $IPAddress) { return ($IPLocationCache | Where-Object { $_.ip -eq $IPAddress } ) Write-Verbose ("IP Cache Hit: " + [string]$IPAddress) } elseif ($IPAddress -eq "<null>"){ write-Verbose ("Null IP Provided: " + $IPAddress) $hash = @{ IP = $IPAddress CountryName = "NULL IP" RegionName = "Unknown" RegionCode = "Unknown" ContinentName = "Unknown" City = "Unknown" KnownMicrosoftIP = "Unknown" } } # If not then we need to look it up and populate it into the cache else { # URI to pull the data from $resource = "http://api.ipstack.com/" + $ipaddress + "?access_key=" + $Accesskey # Return Data from web $Error.Clear() $geoip = Invoke-RestMethod -Method Get -URI $resource -ErrorAction SilentlyContinue if (($Error.Count -gt 0) -or ($null -eq $geoip.type)) { Out-LogFile ("Failed to retreive location for IP " + $IPAddress) -isError $hash = @{ IP = $IPAddress CountryName = "Failed to Resolve" RegionName = "Unknown" RegionCode = "Unknown" ContinentName = "Unknown" City = "Unknown" KnownMicrosoftIP = "Unknown" } } else { # Determine if this IP is known to be owned by Microsoft [string]$isMSFTIP = Test-MicrosoftIP -IPToTest $IPAddress -type $geoip.type if ($isMSFTIP){ $MSFTIP = $isMSFTIP } # Push return into a response object $hash = @{ IP = $geoip.ip CountryName = $geoip.country_name ContinentName = $geoip.continent_name RegionName = $geoip.region_name RegionCode = $geoip.region_code City = $geoip.City KnownMicrosoftIP = $MSFTIP } $result = New-Object PSObject -Property $hash } # Push the result to the global IPLocationCache [array]$Global:IPlocationCache += $result # Return the result to the user return $result } } Function Get-SimpleAdminAuditLog { <# .SYNOPSIS Convert output from search-adminauditlog to be more human readable .DESCRIPTION Convert output from search-adminauditlog to be more human readable .PARAMETER SearchResults Results from query .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Param ( [Parameter( Position = 0, Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true) ] $SearchResults ) # Setup to process incoming results Begin { # Make sure the array is null [array]$ResultSet = $null } # Process thru what ever is comming into the script Process { # Deal with each object in the input $searchresults | ForEach-Object { # Reset the result object $Result = New-Object PSObject # Get the alias of the User that ran the command [string]$user = $_.caller # If it is null then replace with *** for admin call if ([string]::IsNullOrEmpty($user)) { $user = "***" } # if we have 'on behalf of' then we need to do some more processing to get the right value elseif ($_.caller -like "*on behalf of*") { $split = $_.caller.split("/") $Start = (($Split[3].split(" "))[0]).TrimEnd('"') $End = $Split[-1].trimend('"') [string]$User = $Start + " on behalf of " + $end } # If there is a / in the username lests simply it elseif ($_.caller -contains "/") { [string]$user = ($_.caller.split("/"))[-1] } # If none of the above or true just pass it thru else { [string]$user = $_.caller } # Build the command that was run $switches = $_.cmdletparameters [string]$FullCommand = $_.cmdletname # Get all of the switchs and add them in "human" form to the output foreach ($parameter in $switches) { # Format our values depending on what they are so that they are as close # a match as possible for what would have been entered switch -regex ($parameter.value) { # If we have a multi value array put in then we need to break it out and add quotes as needed '[;]' { # Reset the formatted value string $FormattedValue = $null # Split it into an array $valuearray = $switch.current.split(";") # For each entry in the array add quotes if needed and add it to the formatted value string $valuearray | ForEach-Object { if ($_ -match "[ \t]") { $FormattedValue = $FormattedValue + "`"" + $_ + "`";" } else { $FormattedValue = $FormattedValue + $_ + ";" } } # Clean up the trailing ; $FormattedValue = $FormattedValue.trimend(";") # Add our switch + cleaned up value to the command string $FullCommand = $FullCommand + " -" + $parameter.name + " " + $FormattedValue } # If we have a value with spaces add quotes '[ \t]' { $FullCommand = $FullCommand + " -" + $parameter.name + " `"" + $switch.current + "`"" } # If we have a true or false format them with :$ in front ( -allow:$true ) '^True$|^False$' { $FullCommand = $FullCommand + " -" + $parameter.name + ":`$" + $switch.current } # Otherwise just put the switch and the value default { $FullCommand = $FullCommand + " -" + $parameter.name + " " + $switch.current } } } # Format our modified object if ([string]::IsNullOrEmpty($_.objectModified)) { $ObjModified = "" } else { $ObjModified = ($_.objectmodified.split("/"))[-1] $ObjModified = ($ObjModified.split("\"))[-1] } # Get just the name of the cmdlet that was run [string]$cmdlet = $_.CmdletName # Build the result object to return our values $Result | Add-Member -MemberType NoteProperty -Value $user -Name Caller $Result | Add-Member -MemberType NoteProperty -Value $cmdlet -Name Cmdlet $Result | Add-Member -MemberType NoteProperty -Value $FullCommand -Name FullCommand $Result | Add-Member -MemberType NoteProperty -Value ($_.rundate).ToUniversalTime() -Name 'RunDate(UTC)' $Result | Add-Member -MemberType NoteProperty -Value $ObjModified -Name ObjectModified # Add the object to the array to be returned $ResultSet = $ResultSet + $Result } } # Final steps End { # Return the array set Return $ResultSet } } 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 { 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 foreach ($nestedKey in $nestedObject.Keys) { $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) { $counter = 1 while ($flatProperties.ContainsKey("${nestedKey}_$counter")) { $counter++ } "${nestedKey}_$counter" } else { $nestedKey } $flatProperties[$uniqueKey] = $nestedObject[$nestedKey] } } # 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 foreach ($nestedKey in $nestedObject.Keys) { $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) { $counter = 1 while ($flatProperties.ContainsKey("${nestedKey}_$counter")) { $counter++ } "${nestedKey}_$counter" } else { $nestedKey } $flatProperties[$uniqueKey] = $nestedObject[$nestedKey] } } } 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 foreach ($nestedKey in $nestedObject.Keys) { $uniqueKey = if ($flatProperties.ContainsKey($nestedKey)) { $counter = 1 while ($flatProperties.ContainsKey("${nestedKey}_$counter")) { $counter++ } "${nestedKey}_$counter" } else { $nestedKey } $flatProperties[$uniqueKey] = $nestedObject[$nestedKey] } } # 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 } } Function Import-AzureAuthenticationLog { <# .SYNOPSIS Takes in a set of azure Authentication logs and combines them into a unified output .DESCRIPTION Takes in a set of azure Authentication logs and combines them into a unified output .PARAMETER JsonConvertedLogs Logs that are converted .EXAMPLE Import-AzureAuthenticationLog Imprts Azure Auth logs .NOTES General notes #> Param([array]$JsonConvertedLogs) # Null out the output object $Listoutput = $null $baseproperties = $null $i = 0 # Create the output list array $ListOutput = New-Object System.Collections.ArrayList $baseproperties = New-Object System.Collections.ArrayList # Process each entry in the array foreach ($entry in $JsonConvertedLogs) { if ([bool]($i % 25)) { } Else { Write-Progress -Activity "Converting Json Entries" -CurrentOperation ("Entry " + $i) -PercentComplete (($i / $JsonConvertedLogs.count) * 100) -Status ("Processing") } # null out a temp object and create it as a new custom ps object $processedentry = $null $processedentry = New-Object -TypeName PSobject # Look at each member of the entry ... we want to process each in turn and add them to a new object foreach ($member in ($entry | get-member -MemberType NoteProperty)) { # Identity unique properties and add to property list of base object if not present if ($baseproperties -contains $member.name) { } else { $baseproperties.add($member.name) | Out-Null } # Switch statement to deal with known "special" properties switch ($member.name) { # Extended properties can contain addtional values so we need to expand those ExtendedProperties { # Null check if ($null -eq $entry.ExtendedProperties) { } else { # expand out each entry and add it to the base properties and to the property of our exported object Foreach ($Object in $entry.ExtendedProperties) { # Identity unique properties and add to property list of base object if not present if ($baseproperties -contains $object.name) { } else { $baseproperties.add($object.name) | out-null } # For some entries a property can appear in ExtendedProperties and as a normal property # We need to deal with this situation try { # Now add the entry from extendedproperties to the overall properties list $processedentry | Add-Member -MemberType NoteProperty -Name $object.name -Value $object.value -ErrorAction SilentlyContinue } catch { if ((($error[0].FullyQualifiedErrorId).split(",")[0]) -eq "MemberAlreadyExists") { } } } # Convert our extended properties into a string and add that just for fidelity # null the output string [string]$epstring = $null # Convert into a string that is , seperated but with : seperating name and value foreach ($ep in $entry.extendedproperties) { [string]$epstring += $ep.name + ":" + $ep.v + "," } # We also still want to add extendedproperties in as is just for fidelity $processedentry | Add-Member -MemberType NoteProperty -Name ExtendedProperties -Value ($epstring.TrimEnd(",")) } } # Need to convert this from a system object into a string # This is an initial pass at this might be a better way to do it Actor { if ($null -eq $entry.actor) { } else { # null the output string [string]$actorstring = $null # Convert into a string that is , seperated but with : seperating ID and type foreach ($actor in $entry.actor) { [string]$actorstring += $actor.id + ":" + $actor.type + "," } # Add the string to the output $processedentry | Add-Member -MemberType NoteProperty -Name "Actor" -Value ($actorstring.TrimEnd(",")) } } Target { if ($null -eq $entry.target) { } else { # null the output string [string]$targetstring = $null # Convert into a string that is , seperated but with : seperating ID and type foreach ($target in $entry.target) { [string]$targetstring += $target.id + ":" + $target.type + "," } # Add the string to the output $processedentry | Add-Member -MemberType NoteProperty -Name "Target" -Value ($targetstring.TrimEnd(",")) } } Creationtime { $processedentry | Add-Member -MemberType NoteProperty -Name CreationTime -value (get-date $entry.Creationtime -format g) } Default { # For some entries a property can appear in ExtendedProperties and as a normal property # We need to deal with this situation try { # Now add the entry from extendedproperties to the overall properties list $processedentry | Add-Member -MemberType NoteProperty -Name $member.name -Value $entry.($member.name) -ErrorAction SilentlyContinue } catch { if ((($error[0].FullyQualifiedErrorId).split(",")[0]) -eq "MemberAlreadyExists") { } } } } } # Increment our counter $i++ # Add to output object $Listoutput.add($processedentry) | Out-Null } Write-Progress -Completed -Activity "Converting Json Entries" -Status " " # Build a base object using all unique property names $baseobject = $null $baseobject = New-Object -TypeName PSobject foreach ($propertyname in $baseproperties) { switch ($propertyname) { CreationTime { $baseobject | Add-Member -MemberType NoteProperty -Name $propertyname -Value (get-date 01/01/1900 -format g) } Default { $baseobject | Add-Member -MemberType NoteProperty -Name $propertyname -Value "Base" } } } # Add that object to the output $Listoutput.add($baseobject) | Out-Null # Base object HAS to be the first entry in the output so that when it is written to CSV it includes all properties [array]$sortedoutput = $Listoutput | Sort-Object -Property creationtime $sortedoutput = $sortedoutput | Where-Object { $_.ClientIP -ne 'Base' } # Build an ordered arry to use to order the output coloums # Key coloums that we want ordered at the begining of the output [array]$baseorder = "CreationTime", "UserId", "Workload", "ClientIP", "CountryName", "KnownMicrosoftIP" foreach ($coloumheader in $baseorder) { # If the coloum header exists as one of our base properties then add to to coloumorder array and remove from baseproperties list if ($baseproperties -contains $coloumheader) { [array]$coloumorder += $coloumheader $baseproperties.remove($coloumheader) } else { } } # Add all of the remaining base properties to the sort order array [array]$coloumorder += $baseproperties $sortedoutput = $sortedoutput | Select-Object $coloumorder # write-host $baseproperties return $sortedoutput } Function Initialize-HawkGlobalObject { <# .SYNOPSIS Create global variable $Hawk for use by all Hawk cmdlets. .DESCRIPTION Creates the global variable $Hawk and populates it with information needed by the other Hawk cmdlets. * Checks for latest version of the Hawk module * Creates path for output files * Records target start and end dates for searches (in UTC) .PARAMETER Force Switch to force the function to run and allow the variable to be recreated .PARAMETER SkipUpdate Skips checking for the latest version of the Hawk Module .PARAMETER DaysToLookBack Defines the # of days to look back in the availible logs. Valid values are 1-90 .PARAMETER StartDate First day that data will be retrieved (in UTC) .PARAMETER EndDate Last day that data will be retrieved (in UTC) .PARAMETER FilePath Provide an output file path. .PARAMETER NonInteractive Switch to run the command in non-interactive mode. Requires all necessary parameters to be provided via command line rather than through interactive prompts. .OUTPUTS Creates the $Hawk global variable and populates it with a custom PS object with the following properties Property Name Contents ========== ========== FilePath Path to output files DaysToLookBack Number of day back in time we are searching StartDate Calculated start date for searches based on DaysToLookBack (UTC) EndDate One day in the future (UTC) WhenCreated Date and time that the variable was created (UTC) .EXAMPLE Initialize-HawkGlobalObject -Force This Command will force the creation of a new $Hawk variable even if one already exists. #> [CmdletBinding()] param ( [DateTime]$StartDate, [DateTime]$EndDate, [int]$DaysToLookBack, [string]$FilePath, [switch]$SkipUpdate, [switch]$NonInteractive, [switch]$Force ) if ($Force) { Remove-Variable -Name Hawk -Scope Global -ErrorAction SilentlyContinue } # Check for incomplete/interrupted initialization and force a fresh start if ($null -ne (Get-Variable -Name Hawk -ErrorAction SilentlyContinue)) { if (Test-HawkGlobalObject) { Remove-Variable -Name Hawk -Scope Global -ErrorAction SilentlyContinue # Remove other related global variables that might exist Remove-Variable -Name IPlocationCache -Scope Global -ErrorAction SilentlyContinue Remove-Variable -Name MSFTIPList -Scope Global -ErrorAction SilentlyContinue } } Function Test-LoggingPath { param([string]$PathToTest) # Get the current timestamp in the format yyyy-MM-dd HH:mm:ssZ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss'Z'") # First test if the path we were given exists if (Test-Path $PathToTest) { # If the path exists verify that it is a folder if ((Get-Item $PathToTest).PSIsContainer -eq $true) { Return $true } # If it is not a folder return false and write an error else { Write-Information "[$timestamp] - [ERROR] - Path provided $PathToTest was not found to be a folder." Return $false } } # If it doesn't exist then return false and write an error else { Write-Information "[$timestamp] - [ERROR] - Directory $PathToTest Not Found" Return $false } } Function New-LoggingFolder { [OutputType([System.Collections.Hashtable])] [CmdletBinding(SupportsShouldProcess)] param([string]$RootPath) # Get the current timestamp in the format yyyy-MM-dd HH:mm:ssZ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss'Z'") try { # Test Graph connection first to see if we're already connected try { $null = Get-MgOrganization -ErrorAction Stop Write-Information "[$timestamp] - [INFO] - Already connected to Microsoft Graph" } catch { # Only show connecting message if we actually need to connect Write-Information "[$timestamp] - [ACTION] - Connecting to Microsoft Graph" $null = Test-GraphConnection Write-Information "[$timestamp] - [INFO] - Connected to Microsoft Graph Successfully" } # Get tenant name $org = Get-MgOrganization -ErrorAction Stop if (!$org) { throw "Could not retrieve tenant organization information" } # Use display name if available, otherwise fall back to tenant name $TenantName = if ($org.DisplayName) { $org.DisplayName } else { $org.Id } # Remove any invalid file system characters and spaces $TenantName = $TenantName -replace '[\\/:*?"<>|]', '' -replace '\s+', '_' [string]$FolderID = "Hawk_" + $TenantName + "_" + (Get-Date).ToUniversalTime().ToString("yyyyMMdd_HHmmss") $FullOutputPath = Join-Path $RootPath $FolderID if (Test-Path $FullOutputPath) { Write-Information "[$timestamp] - [ERROR] - Path $FullOutputPath already exists" } else { Write-Information "[$timestamp] - [ACTION] - Creating subfolder $FullOutputPath" $null = New-Item $FullOutputPath -ItemType Directory -ErrorAction Stop } # Return both path and tenant name return @{ Path = $FullOutputPath TenantName = $TenantName } } catch { # If it fails at any point, display an error message Write-Error "[$timestamp] - [ERROR] - Failed to create logging folder: $_" } } Function Set-LoggingPath { [CmdletBinding(SupportsShouldProcess)] param ( [string]$Path) # Get the current timestamp in the format yyyy-MM-dd HH:mm:ssZ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss'Z'") # If no value for Path is provided, prompt and gather from the user if ([string]::IsNullOrEmpty($Path)) { # Setup a while loop to get a valid path Do { # Ask the user for the output path [string]$UserPath = (Read-Host "[$timestamp] - [PROMPT] - Please provide an output directory").Trim() # If the input is null or empty, prompt again if ([string]::IsNullOrEmpty($UserPath)) { Write-Host "[$timestamp] - [INFO] - Directory path cannot be empty. Please enter in a new path." $ValidPath = $false } # If the path is valid, create the subfolder elseif (Test-LoggingPath -PathToTest $UserPath) { $folderInfo = New-LoggingFolder -RootPath $UserPath $ValidPath = $true } # If the path is invalid, prompt again else { Write-Information "[$timestamp] - [ERROR] - Path not a valid directory: $UserPath" $ValidPath = $false } } While ($ValidPath -eq $false) } # If a value for Path is provided, validate it else { # If the provided path is valid, create the subfolder if (Test-LoggingPath -PathToTest $Path) { $folderInfo = New-LoggingFolder -RootPath $Path # Changed variable name for clarity } # If the provided path fails validation, stop the process else { Write-Error "[$timestamp] - [ERROR] - Provided path is not a valid directory: $Path" } } Return $folderInfo } Function New-ApplicationInsight { [CmdletBinding(SupportsShouldProcess)] param() # Initialize Application Insights client $insightkey = "aa3d4e74-29c0-4b4c-83ad-865669402baa" if ($Null -eq $Client) { Out-LogFile "Initializing Application Insights" -Action $Client = New-AIClient -key $insightkey } } ### Main ### $InformationPreference = "Continue" if (($null -eq (Get-Variable -Name Hawk -ErrorAction SilentlyContinue)) -or ($Force -eq $true) -or ($null -eq $Hawk)) { if ($NonInteractive) { Write-HawkBanner } else { Write-HawkBanner -DisplayWelcomeMessage } # Create the global $Hawk variable immediately with minimal properties $Global:Hawk = [PSCustomObject]@{ FilePath = $null # Will be set shortly DaysToLookBack = $null StartDate = $null EndDate = $null WhenCreated = $null TenantName = $null } # Set up the file path first, before any other operations # Set up the file path first, before any other operations if ([string]::IsNullOrEmpty($FilePath)) { # Suppress Graph connection output during initial path setup $folderInfo = Set-LoggingPath -ErrorAction Stop $Hawk.FilePath = $folderInfo.Path $Hawk.TenantName = $folderInfo.TenantName } else { $folderInfo = Set-LoggingPath -path $FilePath -ErrorAction Stop 2>$null $Hawk.FilePath = $folderInfo.Path $Hawk.TenantName = $folderInfo.TenantName } # Now that FilePath is set, we can use Out-LogFile Out-LogFile "Hawk output directory created at: $($Hawk.FilePath)" -Information # Setup Application insights Out-LogFile "Setting up Application Insights" -Action New-ApplicationInsight ### Checking for Updates ### # If we are skipping the update log it if ($SkipUpdate) { Out-LogFile -string "Skipping Update Check" -Information } # Check to see if there is an Update for Hawk else { Update-HawkModule } # Test Graph connection Out-LogFile "Testing Graph Connection" -Action Test-GraphConnection if (-not $NonInteractive) { try { $LicenseInfo = Test-LicenseType $MaxDaysToGoBack = $LicenseInfo.RetentionPeriod $LicenseType = $LicenseInfo.LicenseType Out-LogFile -string "Detecting M365 license type to determine maximum log retention period" -action Out-LogFile -string "M365 License type detected: $LicenseType" -Information Out-LogFile -string "Max log retention: $MaxDaysToGoBack days" -action -NoNewLine } catch { Out-LogFile -string "Failed to detect license type. Max days of log retention is unknown." -Information $MaxDaysToGoBack = 90 $LicenseType = "Unknown" } } # Ensure MaxDaysToGoBack does not exceed 365 days if ($MaxDaysToGoBack -gt 365) { $MaxDaysToGoBack = 365 } # Start date validation: Add check for negative numbers while ($null -eq $StartDate) { Write-Output "`n" Out-LogFile "Please specify the first day of the search window:" -isPrompt Out-LogFile " Enter a number of days to go back (1-$MaxDaysToGoBack)" -isPrompt Out-LogFile " OR enter a date in MM/DD/YYYY format" -isPrompt Out-LogFile " Default is 90 days back: " -isPrompt -NoNewLine [string]$StartRead = (Read-Host).Trim() # Determine if input is a valid date # Determine if input is a valid date if ($null -eq ($StartRead -as [DateTime])) { #### Not a DateTime => interpret as # of days #### if ([string]::IsNullOrEmpty($StartRead)) { $StartRead = "90" } # First check if it's a valid integer without converting if (-not ($StartRead -match '^\d+$')) { Out-LogFile -string "Invalid input. Please enter a number between 1 and 365, or a date in MM/DD/YYYY format." -isError continue } # Now safe to convert to integer since we validated the format [int]$StartRead = [int]$StartRead $StartDays = $StartRead # Validate the input is within range # Validate the input is within range if (($StartRead -gt 365) -or ($StartRead -lt 1)) { Out-LogFile -string "Days to go back must be between 1 and 365." -isError Remove-Variable -Name StartDate -ErrorAction SilentlyContinue continue } # Validate the entered days back if ($StartRead -gt $MaxDaysToGoBack) { Out-LogFile -string "The date entered exceeds your license retention period of $MaxDaysToGoBack days." -isWarning Out-LogFile "Press ENTER to proceed or type 'R' to re-enter the date:" -isPrompt -NoNewLine $Proceed = (Read-Host).Trim() if ($Proceed -eq 'R') { Remove-Variable -Name StartDate -ErrorAction SilentlyContinue; continue } } # At this point, we do not yet have EndDate set. So temporarily anchor from "today": [DateTime]$StartDate = ((Get-Date).ToUniversalTime().AddDays(-$StartRead)).Date Out-LogFile -string "Start date set to: ${StartDate}Z" -Information } elseif (!($null -eq ($StartRead -as [DateTime]))) { [DateTime]$StartDate = $StartRead -as [DateTime] # <--- Add this line # ========== The user entered a DateTime, so $StartDays stays 0 ========== # Validate the date if ($StartDate -gt (Get-Date).ToUniversalTime()) { Out-LogFile -string "Start date cannot be in the future." -isError Remove-Variable -Name StartDate -ErrorAction SilentlyContinue continue } if ($StartDate -lt ((Get-Date).ToUniversalTime().AddDays(-$MaxDaysToGoBack))) { Out-LogFile -string "The date entered exceeds your license retention period of $MaxDaysToGoBack days." -isWarning Out-LogFile "Press ENTER to proceed or type 'R' to re-enter the date:" -isPrompt -NoNewLine $Proceed = (Read-Host).Trim() if ($Proceed -eq 'R') { Remove-Variable -Name StartDate -ErrorAction SilentlyContinue; continue } } if ($StartDate -lt ((Get-Date).ToUniversalTime().AddDays(-365))) { Out-LogFile -string "The date cannot exceed 365 days. Setting to the maximum limit of 365 days." -isWarning [DateTime]$StartDate = ((Get-Date).ToUniversalTime().AddDays(-365)).Date } Out-LogFile -string "Start Date: ${StartDate}Z" -Information } else { Out-LogFile -string "Invalid date information provided. Could not determine if this was a date or an integer." -isError $StartDate = $null continue } } # End date logic with enhanced validation while ($null -eq $EndDate) { Write-Output "" Out-LogFile "Please specify the last day of the search window:" -isPrompt Out-LogFile " Enter a number of days to go back from today (1-365)" -isPrompt Out-LogFile " OR enter a specific date in MM/DD/YYYY format" -isPrompt Out-LogFile " Default is today's date:" -isPrompt -NoNewLine $EndRead = (Read-Host).Trim() # End date validation if ($null -eq ($EndRead -as [DateTime])) { if ([string]::IsNullOrEmpty($EndRead)) { [DateTime]$tempEndDate = (Get-Date).ToUniversalTime().Date } else { # Validate input is a positive number if ($EndRead -match '^\-') { Out-LogFile -string "Please enter a positive number of days." -isError continue } # Validate numeric value if ($EndRead -notmatch '^\d+$') { Out-LogFile -string "Invalid input. Please enter a number between 1 and 365, or a date in MM/DD/YYYY format." -isError continue } Out-LogFile -string "End Date: $EndRead days." -Information [DateTime]$tempEndDate = ((Get-Date).ToUniversalTime().AddDays( - ($EndRead - 1))).Date } if ($StartDate -gt $tempEndDate) { Out-LogFile -string "End date must be more recent than start date ($StartDate)" -isError continue } # --- FINAL FIX: Always move to next day at 00:00 UTC --- $tempEndDate = $tempEndDate.ToUniversalTime().Date.AddDays(1) $EndDate = $tempEndDate # Write-Output "" # Out-LogFile -string "End date set to: ${EndDate}Z`n" -Information } elseif (!($null -eq ($EndRead -as [DateTime]))) { [DateTime]$tempEndDate = (Get-Date $EndRead).ToUniversalTime().Date if ($StartDate -gt $tempEndDate) { Out-LogFile -string "End date must be more recent than start date ($StartDate)." -isError continue } elseif ($tempEndDate -gt ((Get-Date).ToUniversalTime().AddDays(1))) { Out-LogFile -string "EndDate too far in the future. Setting EndDate to today." -isWarning $tempEndDate = (Get-Date).ToUniversalTime().Date } # --- FINAL FIX: Always move to next day at 00:00 UTC --- $tempEndDate = $tempEndDate.ToUniversalTime().Date.AddDays(1) $EndDate = $tempEndDate # Out-LogFile -string "End date set to: ${EndDate}Z`n" -Information } else { Out-LogFile -string "Invalid date information provided. Could not determine if this was a date or an integer." -isError continue } } # End date logic remains unchanged except for final +1 day fix if ($null -eq $EndDate) { Out-LogFile "Please specify the last day of the search window:" -isPrompt Out-LogFile " Enter a number of days to go back from today (1-365)" -isPrompt Out-LogFile " OR enter a specific date in MM/DD/YYYY format" -isPrompt Out-LogFile " Default is today's date:" -isPrompt -NoNewLine $EndRead = (Read-Host).Trim() # End date validation if ($null -eq ($EndRead -as [DateTime])) { if ([string]::IsNullOrEmpty($EndRead)) { [DateTime]$EndDate = (Get-Date).ToUniversalTime().Date } else { Out-LogFile -string "End Date: $EndRead days." -Information [DateTime]$EndDate = ((Get-Date).ToUniversalTime().AddDays( - ($EndRead - 1))).Date } if ($StartDate -gt $EndDate) { Out-LogFile -string "StartDate cannot be more recent than EndDate" -isError } else { # --- FINAL FIX: Always move to next day at 00:00 UTC --- $EndDate = $EndDate.ToUniversalTime().Date.AddDays(1) # Write-Output "" # Out-LogFile -string "End date set to: ${EndDate}Z`n" -Information } } elseif (!($null -eq ($EndRead -as [DateTime]))) { [DateTime]$EndDate = (Get-Date $EndRead).ToUniversalTime().Date if ($StartDate -gt $EndDate) { Out-LogFile -string "EndDate is earlier than StartDate. Setting EndDate to today." -isWarning [DateTime]$EndDate = (Get-Date).ToUniversalTime().Date } elseif ($EndDate -gt ((Get-Date).ToUniversalTime().AddDays(1))) { Out-LogFile -string "EndDate too far in the future. Setting EndDate to today." -isWarning [DateTime]$EndDate = (Get-Date).ToUniversalTime().Date } # --- FINAL FIX: Always move to next day at 00:00 UTC --- $EndDate = $EndDate.ToUniversalTime().Date.AddDays(1) # Out-LogFile -string "End date set to: ${EndDate}Z`n" -Information } else { Out-LogFile -string "Invalid date information provided. Could not determine if this was a date or an integer." -isError } } # --- AFTER the EndDate block, do a final check to "re-anchor" StartDate if it was given in days --- if ($StartDays -gt 0) { # Recalculate StartDate based on EndDate = $EndDate and StartDays = $StartDays Out-LogFile -string "End date set to midnight UTC of next day to include all data from $($EndDate.AddDays(-1).Date.ToString('yyyy-MM-dd'))Z" -Information $StartDate = $EndDate.AddDays(-1).AddDays(-$StartDays).Date # (Optional) Additional validations again if necessary: if ($StartDate -gt (Get-Date).ToUniversalTime()) { Out-LogFile -string "Start date is in the future. Resetting to today's date." -isWarning $StartDate = (Get-Date).ToUniversalTime().Date } # If EndDate is today, adjust to current time if ($EndDate.Date -eq (Get-Date).Date) { $EndDate = (Get-Date).ToUniversalTime() Out-LogFile -string "Adjusting EndDate to current time: $EndDate" -Information } } # Configuration Example, currently not used #TODO: Implement Configuration system across entire project Set-PSFConfig -Module 'Hawk' -Name 'DaysToLookBack' -Value $Days -PassThru | Register-PSFConfig if ($OutputPath) { Set-PSFConfig -Module 'Hawk' -Name 'FilePath' -Value $OutputPath -PassThru | Register-PSFConfig } # Continue populating the Hawk object with other properties $Hawk.DaysToLookBack = $DaysToLookBack $Hawk.StartDate = $StartDate $Hawk.EndDate = $EndDate $Hawk.WhenCreated = (Get-Date).ToUniversalTime().ToString("g") Write-HawkConfigurationComplete -Hawk $Hawk } else { Out-LogFile -string "Valid Hawk Object already exists no actions will be taken." -Information } } <# .SYNOPSIS Output hawk appdata to a file .DESCRIPTION Output hawk appdata to a file .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Out-HawkAppData { $HawkAppdataPath = join-path $env:LOCALAPPDATA "Hawk\Hawk.json" $HawkAppdataFolder = join-path $env:LOCALAPPDATA "Hawk" # test if the folder exists if (test-path $HawkAppdataFolder) { } # if it doesn't we need to create it else { $null = New-Item -ItemType Directory -Path $HawkAppdataFolder } Out-LogFile ("Recording HawkAppData to file " + $HawkAppdataPath) -Action $global:HawkAppData | ConvertTo-Json | Out-File -FilePath $HawkAppdataPath -Force } Function Out-LogFile { <# .SYNOPSIS Writes output to a log file with a time date stamp. .DESCRIPTION Writes output to a log file with a time date stamp and appropriate prefixes based on the type of message. By default, messages are also displayed on the screen unless the -NoDisplay switch is used. Message types: - Action: Represent ongoing operations or procedures. - Error: Represent failures, exceptions, or error conditions that prevented successful execution. - Investigate (notice, silentnotice): Represent events that require attention or hold investigative value. - Information: Represent successful completion or informational status updates that do not require action or investigation. - Warning: Indicates warning conditions that need attention but aren't errors. - Prompt: Indicates user input is being requested. .PARAMETER string The log message to be written. .PARAMETER action Switch indicating the log entry is describing an action being performed. .PARAMETER isError Switch indicating the log entry represents an error condition or failure. The output is prefixed with [ERROR] in the log file. .PARAMETER notice Switch indicating the log entry requires investigation or special attention. .PARAMETER silentnotice Switch indicating additional investigative information that should not be displayed on the screen. This is logged to the file but suppressed in console output. .PARAMETER NoDisplay Switch indicating the message should only be written to the log file, not displayed in the console. .PARAMETER Information Switch indicating the log entry provides informational status or completion messages, for example: "Retrieved all results" or "Completed data export successfully." .PARAMETER isWarning Switch indicating the log entry is a warning message. The output is prefixed with [WARNING] in the log file. .PARAMETER isPrompt Switch indicating the log entry is a user prompt message. The output is prefixed with [PROMPT] in the log file. .PARAMETER NoNewLine Switch indicating the message should be written without a newline at the end, useful for prompts where input should appear on the same line. .EXAMPLE Out-LogFile "Routine scan completed." Writes a simple log message with a UTC timestamp to the log file and displays it on the screen. .EXAMPLE Out-LogFile "Starting mailbox export operation" -action Writes a log message indicating an action is being performed. The output is prefixed with [ACTION] in the log file. .EXAMPLE Out-LogFile "Failed to connect to Exchange Online" -isError Writes a log message indicating an error condition. The output is prefixed with [ERROR] in the log file. .EXAMPLE Out-LogFile "Enter your selection: " -isPrompt -NoNewLine Writes a prompt message without a newline so user input appears on the same line. The output is prefixed with [PROMPT] in the log file. .EXAMPLE Out-LogFile "Detected suspicious login attempt from external IP" -notice Writes a log message indicating a situation requiring investigation. The output is prefixed with [INVESTIGATE] and also recorded in a separate _Investigate.txt file. .EXAMPLE Out-LogFile "User mailbox configuration details" -silentnotice Writes investigative detail to the log and _Investigate.txt file without printing to the console. This is useful for adding detail to a previously logged [INVESTIGATE] event without cluttering the console. .EXAMPLE Out-LogFile "Retrieved all results successfully" -Information Writes a log message indicating a successful or informational event. The output is prefixed with [INFO], suitable for status updates or completion notices. .EXAMPLE Out-LogFile "System resource warning: High CPU usage" -isWarning Writes a warning message to indicate a concerning but non-critical condition. The output is prefixed with [WARNING] in the log file. .EXAMPLE Out-LogFile "Executing periodic health check" -NoDisplay Writes a log message to the file without displaying it on the console, useful for routine logging that doesn't need immediate user visibility. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string]$string, [switch]$action, [switch]$notice, [switch]$silentnotice, [switch]$isError, [switch]$NoDisplay, [switch]$Information, [switch]$isWarning, [switch]$isPrompt, [switch]$NoNewLine ) Write-PSFMessage -Message $string -ModuleName Hawk -FunctionName (Get-PSCallstack)[1].FunctionName # Make sure we have the Hawk Global Object if ([string]::IsNullOrEmpty($Hawk.FilePath)) { Initialize-HawkGlobalObject } # Get our log file path $LogFile = Join-path $Hawk.FilePath "Hawk.log" $ScreenOutput = -not $NoDisplay $LogOutput = $true # Get the current date in UTC [string]$timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ssZ") [string]$logstring = "" # Build the log string based on the type of message if ($action) { $logstring = "[$timestamp] - [ACTION] - $string" } elseif ($isError) { $logstring = "[$timestamp] - [ERROR] - $string" } elseif ($notice) { $logstring = "[$timestamp] - [INVESTIGATE] - $string" # Write to the investigation file [string]$InvestigateFile = Join-Path (Split-Path $LogFile -Parent) "_Investigate.txt" $logstring | Out-File -FilePath $InvestigateFile -Append } elseif ($silentnotice) { $logstring = "[$timestamp] - [INVESTIGATE] - Additional Information: $string" # Write to the investigation file [string]$InvestigateFile = Join-Path (Split-Path $LogFile -Parent) "_Investigate.txt" $logstring | Out-File -FilePath $InvestigateFile -Append # Suppress regular output for silentnotice $ScreenOutput = $false $LogOutput = $false } elseif ($Information) { $logstring = "[$timestamp] - [INFO] - $string" } elseif ($isWarning) { $logstring = "[$timestamp] - [WARNING] - $string" } elseif ($isPrompt) { $logstring = "[$timestamp] - [PROMPT] - $string" } else { $logstring = "[$timestamp] - $string" } # Write to log file if enabled if ($LogOutput) { $logstring | Out-File -FilePath $LogFile -Append } # Write to screen if enabled if ($ScreenOutput) { if ($NoNewLine) { Write-Host $logstring -InformationAction Continue -NoNewLine } else { Write-Information $logstring -InformationAction Continue } } } <# .SYNOPSIS Sends the output of a cmdlet to a txt file and a clixml file .DESCRIPTION Sends the output of a cmdlet to a txt file and a clixml file .PARAMETER Object Incoming object data .PARAMETER FilePrefix File name .PARAMETER User User that the data is being exported from .PARAMETER Append Change existing file .PARAMETER xml xml file format .PARAMETER csv csv file format .PARAMETER txt txt file format .PARAMETER json Export data in JSON format. The data will be converted using ConvertTo-Json with a depth of 100 to preserve object structure. .PARAMETER Notice Notification that data retrieved meets the investigation criteria .EXAMPLE Out-MultipleFileTime Determined what file is being used for export of data .NOTES Need to review invesigation criteria of data being exported #> Function Out-MultipleFileType { param ( [Parameter (ValueFromPipeLine = $true)] $Object, [Parameter (Mandatory = $true)] [string]$FilePrefix, [string]$User, [switch]$Append = $false, [switch]$xml = $false, [Switch]$csv = $false, [Switch]$txt = $false, [Switch]$json = $false, [Switch]$Notice ) begin { # If no file types were specified then we need to error out here if (($xml -eq $false) -and ($csv -eq $false) -and ($txt -eq $false) -and ($json -eq $false)) { Out-LogFile "No output type specified on object" -isError Write-Error -Message "No output type specified on object" -ErrorAction Stop } # Null out our array [array]$AllObject = $null # Set the output path if ([string]::IsNullOrEmpty($User)) { $path = join-path $Hawk.filepath "\Tenant" # Test the path if it is there do nothing otherwise create it if (test-path $path) { } else { Out-LogFile ("Making output directory for Tenant " + $Path) -Action $Null = New-Item $Path -ItemType Directory } } else { $path = join-path $Hawk.filepath $user # Set a bool so we know this is a user output [bool]$UserOutput = $true # Build short name of user so that it is easier to read [string]$ShortUser = ($User.split('@'))[0] # Test the path if it is there do nothing otherwise create it if (test-path $path) { } else { Out-LogFile ("Making output directory for user " + $Path) -Action $Null = New-Item $Path -ItemType Directory } } } process { # Collect up all of the incoming data into a single object for processing and output [array]$AllObject = $AllObject + $Object } end { if ($null -eq $AllObject) { Out-LogFile "No Data Found" -Action } else { # Determine what file type or types we need to write this object into and output it # Output XML File if ($xml -eq $true) { # lets put the xml files in a seperate directory to not clutter things up $xmlpath = Join-path $Path XML if (Test-path $xmlPath) { } else { Out-LogFile ("Making output directory for xml files " + $xmlPath) -Action $null = New-Item $xmlPath -ItemType Directory } # Build the file name and write it out if ($UserOutput) { $filename = Join-Path $xmlpath ($FilePrefix + "_" + $ShortUser + ".xml") } else { $filename = Join-Path $xmlPath ($FilePrefix + ".xml") } Out-LogFile ("Writing Data to " + $filename) -Action # Output our objects to clixml $AllObject | Export-Clixml $filename # If notice is set we need to write the file name to _Investigate.txt if ($Notice) { Out-LogFile -string ($filename) -silentnotice } } # Output CSV file if ($csv -eq $true) { # Build the file name if ($UserOutput) { $filename = Join-Path $Path ($FilePrefix + "_" + $ShortUser + ".csv") } else { $filename = Join-Path $Path ($FilePrefix + ".csv") } # If we have -append then append the data if ($append) { Out-LogFile ("Appending Data to " + $filename) -NoDisplay # Write it out to csv making sture to append $AllObject | Export-Csv $filename -NoTypeInformation -Append -Encoding UTF8 } # Otherwise overwrite else { Out-LogFile ("Writing Data to " + $filename) -Action $AllObject | Export-Csv $filename -NoTypeInformation -Encoding UTF8 } # If notice is set we need to write the file name to _Investigate.txt if ($Notice) { Out-LogFile -string ($filename) -silentnotice } } # Output Text files if ($txt -eq $true) { # Build the file name if ($UserOutput) { $filename = Join-Path $Path ($FilePrefix + "_" + $ShortUser + ".txt") } else { $filename = Join-Path $Path ($FilePrefix + ".txt") } # If we have -append then append the data if ($Append) { Out-LogFile ("Appending Data to " + $filename) -NoDisplay $AllObject | Format-List * | Out-File $filename -Append } # Otherwise overwrite else { Out-LogFile ("Writing Data to " + $filename) -Action $AllObject | Format-List * | Out-File $filename } # If notice is set we need to write the file name to _Investigate.txt if ($Notice) { Out-LogFile -string ($filename) -silentnotice } } # Output JSON file if ($json -eq $true) { # Build the file name if ($UserOutput) { $filename = Join-Path $Path ($FilePrefix + "_" + $ShortUser + ".json") } else { $filename = Join-Path $Path ($FilePrefix + ".json") } # If we have -append then append the data if ($append) { Out-LogFile ("Appending Data to " + $filename) -NoDisplay # Write it out to json making sture to append $AllObject | ConvertTo-Json -Depth 100 | Out-File -FilePath $filename -Encoding UTF8 -Append } # Otherwise overwrite else { Out-LogFile ("Writing Data to " + $filename) -Action $AllObject | ConvertTo-Json -Depth 100 | Out-File -FilePath $filename -Encoding utf8 } # If notice is set we need to write the file name to _Investigate.txt if ($Notice) { Out-LogFile -string ($filename) -silentnotice } } } } } Function Out-Report { <# .SYNOPSIS Adds the data to an XML report .DESCRIPTION Adds the data to an XML report .PARAMETER Identity User Identity for the report .PARAMETER Property xml property .PARAMETER Value Value of xml element .PARAMETER Description Description of element .PARAMETER State Color mapping .PARAMETER Link Element link .EXAMPLE Out-Report Add the data to an XML report .NOTES General notes #> Param ( [Parameter(Mandatory = $true)] [string]$Identity, [Parameter(Mandatory = $true)] [string]$Property, [Parameter(Mandatory = $true)] [string]$Value, [string]$Description, [string]$State, [string]$Link ) # Force the case on all our critical values #$Property = $Property.tolower() #$Identity = $Identity.tolower() # Set our output path # Single report file for all outputs user/tenant/etc. # This might change in the future??? $reportpath = Join-path $hawk.filepath report.xml # Switch statement to handle the state to color mapping switch ($State) { Warning { $highlighcolor = "#FF8000" } Success { $highlighcolor = "Green" } Error { $highlighcolor = "#8A0808" } default { $highlighcolor = "Light Grey" } } # Check if we have our XSL file in the output directory $xslpath = Join-path $hawk.filepath Report.xsl if (Test-Path $xslpath ) { } else { # Copy the XSL file into the current output path $sourcepath = join-path (split-path (Get-Module Hawk).path) report.xsl if (test-path $sourcepath) { Copy-Item -Path $sourcepath -Destination $hawk.filepath } # If we couldn't find it throw and error and stop else { Write-Error ("Unable to find transform file " + $sourcepath) -ErrorAction Stop } } # See if we have already created a report file # If so we need to import it if (Test-path $reportpath) { $reportxml = $null [xml]$reportxml = get-content $reportpath } # Since we have NOTHING we will create a new XML and just add / save / and exit else { Out-LogFile ("Creating new Report file" + $reportpath) # Create the report xml object $reportxml = New-Object xml # Create the xml declaraiton and stylesheet $reportxml.AppendChild($reportxml.CreateXmlDeclaration("1.0", $null, $null)) | Out-Null # $xmlstyle = "type=`"text/xsl`" href=`"https://csshawk.azurewebsites.net/report.xsl`"" # $reportxml.AppendChild($reportxml.CreateProcessingInstruction("xml-stylesheet",$xmlstyle)) | Out-Null # Create all of the needed elements $newreport = $reportxml.CreateElement("report") $newentity = $reportxml.CreateElement("entity") $newentityidentity = $reportxml.CreateElement("identity") $newentityproperty = $reportxml.CreateElement("property") $newentitypropertyname = $reportxml.CreateElement("name") $newentitypropertyvalue = $reportxml.CreateElement("value") $newentitypropertycolor = $reportxml.CreateElement("color") $newentitypropertydescription = $reportxml.CreateElement("description") $newentitypropertylink = $reportxml.CreateElement("link") ### Build the XML from the bottom up ### # Add the property values to the entity object $newentityproperty.AppendChild($newentitypropertyname) | Out-Null $newentityproperty.AppendChild($newentitypropertyvalue) | Out-Null $newentityproperty.AppendChild($newentitypropertycolor) | Out-Null $newentityproperty.AppendChild($newentitypropertydescription) | Out-Null $newentityproperty.AppendChild($newentitypropertylink) | Out-Null # Set the values for the leaf nodes we just added $newentityproperty.name = $Property $newentityproperty.value = $Value $newentityproperty.color = $highlighcolor $newentityproperty.description = $Description $newentityproperty.link = $Link # Add the identity element to the entity and set its value $newentity.AppendChild($newentityidentity) | Out-Null $newentity.identity = $Identity # Add the property to the entity $newentity.AppendChild($newentityproperty) | Out-Null # Add the entity to the report $newreport.AppendChild($newentity) | Out-Null # Add the whole thing to the xml root $reportxml.AppendChild($newreport) | Out-Null # save the xml $reportxml.save($reportpath) } # We need to check if an entity with the ID $identity already exists if ($reportxml.report.entity.identity.contains($Identity)) { } # Didn't find and entity so we are going to create the whole thing and once else { # Create all of the needed elements $newentity = $reportxml.CreateElement("entity") $newentityidentity = $reportxml.CreateElement("identity") $newentityproperty = $reportxml.CreateElement("property") $newentitypropertyname = $reportxml.CreateElement("name") $newentitypropertyvalue = $reportxml.CreateElement("value") $newentitypropertycolor = $reportxml.CreateElement("color") $newentitypropertydescription = $reportxml.CreateElement("description") $newentitypropertylink = $reportxml.CreateElement("link") ### Build the XML from the bottom up ### # Add the property values to the entity object $newentityproperty.AppendChild($newentitypropertyname) | Out-Null $newentityproperty.AppendChild($newentitypropertyvalue) | Out-Null $newentityproperty.AppendChild($newentitypropertycolor) | Out-Null $newentityproperty.AppendChild($newentitypropertydescription) | Out-Null $newentityproperty.AppendChild($newentitypropertylink) | Out-Null # Set the values for the leaf nodes we just added $newentityproperty.name = $Property $newentityproperty.value = $Value $newentityproperty.color = $highlighcolor $newentityproperty.description = $Description $newentityproperty.link = $Link # Add them together and set values $newentity.AppendChild($newentityidentity) | Out-Null $newentity.identity = $Identity $newentity.AppendChild($newentityproperty) | Out-Null # Add the new entity stub back to the XML $reportxml.report.AppendChild($newentity) | Out-Null } # Now we need to check for the property we are looking to add # The property exists so we need to update it if (($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).property.name.contains($Property)) { ### Update existing property ### (($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).property | Where-Object { $_.name -eq $Property }).value = $Value (($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).property | Where-Object { $_.name -eq $Property }).color = $highlighcolor (($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).property | Where-Object { $_.name -eq $Property }).description = $Description (($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).property | Where-Object { $_.name -eq $Property }).link = $Link } # We need to add the property to the entity else { ### Add new property to existing Entity ### # Create the elements that we are going to need $newproperty = $reportxml.CreateElement("property") $newname = $reportxml.CreateElement("name") $newvalue = $reportxml.CreateElement("value") $newcolor = $reportxml.CreateElement("color") $newdescription = $reportxml.CreateElement("description") $newlink = $reportxml.CreateElement("link") # Add on all of the elements $newproperty.AppendChild($newname) | Out-Null $newproperty.AppendChild($newvalue) | Out-Null $newproperty.AppendChild($newcolor) | Out-Null $newproperty.AppendChild($newdescription) | Out-Null $newproperty.AppendChild($newlink) | Out-Null # Set the values $newproperty.name = $Property $newproperty.value = $Value $newproperty.color = $highlighcolor $newproperty.description = $Description $newproperty.link = $Link # Add the newly created property to the entity ($reportxml.report.entity | Where-Object { $_.identity -eq $Identity }).AppendChild($newproperty) | Out-Null } # Make sure we save our changes $reportxml.Save($reportpath) # Convert it to HTML and Save Convert-ReportToHTML -Xml $reportpath -Xsl $xslpath } <# .SYNOPSIS Read in hawk app data if it is there .DESCRIPTION Read in hawk app data if it is there .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Read-HawkAppData { $HawkAppdataPath = join-path $env:LOCALAPPDATA "Hawk\Hawk.json" # check to see if our xml file is there if (test-path $HawkAppdataPath) { Out-LogFile ("Reading file " + $HawkAppdataPath) -Action $global:HawkAppData = ConvertFrom-Json -InputObject ([string](Get-Content $HawkAppdataPath)) } # if we don't have an xml file then do nothing else { Out-LogFile ("No HawkAppData File found " + $HawkAppdataPath) -Information } } Function Reset-HawkEnvironment { <# .SYNOPSIS Resets all Hawk-related variables to allow for a fresh instance. .DESCRIPTION This function removes all global variables used by Hawk, including the main Hawk object, IP location cache, and Microsoft IP list. This allows you to start fresh with Hawk without needing to close and reopen your PowerShell window. Variables removed: - $Hawk (Main configuration object) - $IPlocationCache (IP geolocation cache) - $MSFTIPList (Microsoft IP address list) - $HawkAppData (Application data) .PARAMETER Confirm Prompts for confirmation before executing the command. Specify -Confirm:$false to suppress confirmation prompts. .PARAMETER WhatIf Shows what would happen if the command runs. The command is not executed. .EXAMPLE Reset-HawkEnvironment Removes all Hawk-related variables and confirms when ready for a fresh start. .EXAMPLE Reset-HawkEnvironment -Verbose Removes all Hawk-related variables with detailed progress messages. .EXAMPLE Reset-HawkEnvironment -WhatIf Shows what variables would be removed without actually removing them. .NOTES Author: Jonathan Butler Version: 1.0 Last Modified: 2025-01-20 This function should be used when you need to start a fresh Hawk investigation without closing your PowerShell session. #> [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')] param() # Store original preference $originalInformationPreference = $InformationPreference $InformationPreference = 'Continue' Write-Information "Beginning Hawk environment cleanup..." # List of known Hawk-related variables to remove $hawkVariables = @( 'Hawk', # Main Hawk configuration object 'IPlocationCache', # IP geolocation cache 'MSFTIPList', # Microsoft IP address list 'HawkAppData' # Hawk application data ) foreach ($varName in $hawkVariables) { if (Get-Variable -Name $varName -ErrorAction SilentlyContinue) { try { if ($PSCmdlet.ShouldProcess("Variable $varName", "Remove")) { Remove-Variable -Name $varName -Scope Global -Force -ErrorAction Stop Write-Information "Successfully removed `$$varName" } } catch { Write-Warning "Failed to remove `$$varName : $_" } } else { Write-Information "`$$varName was not present" } } # Clear any PSFramework configuration cache if ($PSCmdlet.ShouldProcess("PSFramework cache", "Clear")) { if (Get-Command -Name 'Clear-PSFResultCache' -ErrorAction SilentlyContinue) { Clear-PSFResultCache Write-Information "Cleared PSFramework result cache" } } Write-Information "`nHawk environment has been reset!" Write-Information "You can now run Initialize-HawkGlobalObject for a fresh start.`n" # Restore original preference $InformationPreference = $originalInformationPreference } <# .SYNOPSIS Returns a collection of unique objects filtered by a single property .DESCRIPTION Returns a collection of unique objects filtered by a single property .PARAMETER ObjectArray Array of objects .PARAMETER Property Property of the collection of unique objects .EXAMPLE Select-UniqueObject Selects unique objects for investigation .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Select-UniqueObject { param ( [Parameter(Mandatory = $true)] [array]$ObjectArray, [Parameter(Mandatory = $true)] [string]$Property ) # Null out our output array [array]$Output = $null # Get the ID of the unique objects based on the sort property [array]$UniqueObjectID = $ObjectArray | Select-Object -Unique -ExpandProperty $Property # Select the whole object based on the unique names found foreach ($Name in $UniqueObjectID) { [array]$Output = $Output + ($ObjectArray | Where-Object { $_.($Property) -eq $Name } | Select-Object -First 1) } return $Output } <# .SYNOPSIS Sleeps X seconds and displays a progress bar .DESCRIPTION Sleeps X seconds and displays a progress bar .PARAMETER sleeptime Lengthe of sleep time .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Start-SleepWithProgress { Param([int]$sleeptime) # Loop Number of seconds you want to sleep For ($i = 0; $i -le $sleeptime; $i++) { $timeleft = ($sleeptime - $i); # Progress bar showing progress of the sleep Write-Progress -Activity "Sleeping" -CurrentOperation "$Timeleft More Seconds" -PercentComplete (($i / $sleeptime) * 100); # Sleep 1 second start-sleep 1 } Write-Progress -Completed -Activity "Sleeping" } <# .SYNOPSIS Test if we are connected to the compliance center online and connect if not .DESCRIPTION Test if we are connected to the compliance center online and connect if not .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Test-CCOConnection { Write-Output "Not yet implemented" } <# .SYNOPSIS Test if we are connected to Exchange Online and connect if not .DESCRIPTION Test if we are connected to Exchange Online and connect if not .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Test-EXOConnection { # In all cases make sure we are "connected" to EXO try { $null = Get-OrganizationConfig -erroraction stop } catch [System.Management.Automation.CommandNotFoundException] { # Connect to EXO if we couldn't find the command Out-LogFile "Not Connected to Exchange Online" -Information Out-LogFile "Connecting to EXO using Exchange Online Module" -Action Connect-ExchangeOnline } } <# .SYNOPSIS Test if we are connected to Graph and connect if not .DESCRIPTION Test if we are connected to Graph and connect if not .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES https://learn.microsoft.com/en-us/powershell/microsoftgraph/get-started?view=graph-powershell-1.0 #> Function Test-GraphConnection { try { $null = Get-MgOrganization -ErrorAction Stop } catch { # Fallback if $Hawk is not initialized $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss") if ($null -eq $Hawk) { # Use standardized timestamp format when Hawk isn't initialized Write-Output "[$timestamp UTC] - [ACTION] - Connecting to Microsoft Graph API" } else { # $Hawk exists, so we can safely use Out-LogFile Write-Output "[$timestamp UTC] - [ACTION] - Connecting to Microsoft Graph API" } Connect-MGGraph } } Function Test-HawkDateParameter { <# .SYNOPSIS Internal helper function that processes and validates date parameters for Hawk investigations. .DESCRIPTION The Test-HawkDateParmeter function is an internal helper used by Start-HawkTenantInvestigation and Start-HawkUserInvestigation to process date-related parameters. It handles both direct date specifications and the DaysToLookBack parameter, performing initial validation and date conversions. The function: - Validates the combination of provided date parameters - Processes DaysToLookBack into concrete start/end dates - Converts dates to UTC format - Performs bounds checking on date ranges - Handles both absolute dates and relative date calculations This is designed as an internal function and should not be called directly by end users. .PARAMETER PSBoundParameters The PSBoundParameters hashtable from the calling function. Used to check which parameters were explicitly passed to the parent function. Must contain information about whether StartDate, EndDate, and/or DaysToLookBack were provided. .PARAMETER StartDate The starting date for the investigation period, if specified directly. Can be null if using DaysToLookBack instead. When provided with EndDate, defines an explicit date range for the investigation. .PARAMETER EndDate The ending date for the investigation period, if specified directly. Can be null if using DaysToLookBack instead. When provided with StartDate, defines an explicit date range for the investigation. .PARAMETER DaysToLookBack The number of days to look back from either the current date or a specified EndDate. Must be between 1 and 365. Cannot be used together with StartDate. .OUTPUTS PSCustomObject containing: - StartDate [DateTime]: The calculated or provided start date in UTC - EndDate [DateTime]: The calculated or provided end date in UTC .EXAMPLE $dates = Test-HawkDateParameter -PSBoundParameters $PSBoundParameters -DaysToLookBack 30 Processes a request to look back 30 days from the current date, returning appropriate start and end dates in UTC format. .EXAMPLE $dates = Test-HawkDateParameter ` -PSBoundParameters $PSBoundParameters ` -StartDate "2024-01-01" ` -EndDate "2024-01-31" Processes explicit start and end dates, validating them and converting to UTC format. .EXAMPLE $dates = Test-HawkDateParameter ` -PSBoundParameters $PSBoundParameters ` -DaysToLookBack 30 ` -EndDate "2024-01-31" Processes a request to look back 30 days from a specific end date. .NOTES Author: Jonathan Butler Internal Function: This function is not meant to be called directly by users Dependencies: Requires PSFramework module for error handling Validation: Initial parameter validation only; complete validation is done by Test-HawkInvestigationParameter #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [hashtable]$PSBoundParameters, [AllowNull()] [Nullable[DateTime]]$StartDate, [AllowNull()] [Nullable[DateTime]]$EndDate, [int]$DaysToLookBack ) # Check if user provided both StartDate AND DaysToLookBack if ($PSBoundParameters.ContainsKey('DaysToLookBack') -and $PSBoundParameters.ContainsKey('StartDate')) { Stop-PSFFunction -Message "DaysToLookBack cannot be used together with StartDate in non-interactive mode." -EnableException $true } # Must specify either StartDate or DaysToLookBack if (-not $PSBoundParameters.ContainsKey('DaysToLookBack') -and -not $PSBoundParameters.ContainsKey('StartDate')) { Stop-PSFFunction -Message "Either StartDate or DaysToLookBack must be specified in non-interactive mode" -EnableException $true } # Process DaysToLookBack if provided if ($PSBoundParameters.ContainsKey('DaysToLookBack')) { if ($DaysToLookBack -lt 1 -or $DaysToLookBack -gt 365) { Stop-PSFFunction -Message "DaysToLookBack must be between 1 and 365" -EnableException $true } if ($PSBoundParameters.ContainsKey('EndDate') -and -not $PSBoundParameters.ContainsKey('StartDate')) { # Check EndDate is not more than one day in future $tomorrow = (Get-Date).ToUniversalTime().Date.AddDays(1) if ($EndDate.ToUniversalTime().Date -gt $tomorrow) { Stop-PSFFunction -Message "EndDate cannot be more than one day in the future" -EnableException $true } $EndDateUTC = $EndDate.ToUniversalTime().Date.AddDays(1) $StartDateUTC = $EndDate.ToUniversalTime().Date.AddDays(-$DaysToLookBack) $StartDate = $StartDateUTC $EndDate = $EndDateUTC } else { # Convert DaysToLookBack to StartDate/EndDate $ConvertedDates = Convert-HawkDaysToDate -DaysToLookBack $DaysToLookBack $StartDate = $ConvertedDates.StartDate $EndDate = $ConvertedDates.EndDate } } else { # For explicit start/end dates if ($StartDate) { $StartDate = $StartDate.ToUniversalTime().Date } if ($EndDate) { # Validate against tomorrow to allow for the extra day $tomorrow = (Get-Date).ToUniversalTime().Date.AddDays(1) if ($EndDate.ToUniversalTime().Date -gt $tomorrow) { Stop-PSFFunction -Message "EndDate cannot be more than one day in the future" -EnableException $true } # Add one day to include full end date $EndDate = $EndDate.ToUniversalTime().Date.AddDays(1) } # Validate date range if ($StartDate -and $EndDate) { if ($StartDate -gt $EndDate) { Stop-PSFFunction -Message "StartDate must be before EndDate" -EnableException $true } # Test against 366 days to account for extra day added for last day inclusiveness $daysDifference = ($EndDate.Date - $StartDate.Date).Days if ($daysDifference -gt 366) { Stop-PSFFunction -Message "Date range cannot exceed 365 days" -EnableException $true } } } [PSCustomObject]@{ StartDate = $StartDate EndDate = $EndDate } } Function Test-HawkGlobalObject { <# .SYNOPSIS Tests if the Hawk global object exists and is properly initialized. .DESCRIPTION This is an internal helper function that verifies whether the Hawk global object exists and contains all required properties properly initialized. It checks for: - FilePath property existence and value - StartDate property existence and value - EndDate property existence and value .EXAMPLE Test-HawkGlobalObject Returns $true if Hawk object is properly initialized, $false otherwise. .OUTPUTS Boolean indicating if reinitialization is needed #> [CmdletBinding()] [OutputType([bool])] param() # Return true (needs initialization) if: # - Hawk object doesn't exist # - Any required property is missing or null if ([string]::IsNullOrEmpty($Hawk.FilePath) -or $null -eq $Hawk.StartDate -or $null -eq $Hawk.EndDate -or ($Hawk.PSObject.Properties.Name -contains 'StartDate' -and $null -eq $Hawk.StartDate) -or ($Hawk.PSObject.Properties.Name -contains 'EndDate' -and $null -eq $Hawk.EndDate)) { return $true } # Hawk object exists and is properly initialized return $false } Function Test-HawkInvestigationParameter { <# .SYNOPSIS Validates parameters for Hawk investigation commands in both interactive and non-interactive modes. .DESCRIPTION The Test-HawkInvestigationParameters function performs comprehensive validation of parameters used in Hawk's investigation commands. It ensures that all required parameters are present and valid when running in non-interactive mode, while also validating date ranges and other constraints that apply in both modes. The function validates: - File path existence and validity - Presence of required date parameters in non-interactive mode - Date range constraints (max 365 days, start before end) - DaysToLookBack value constraints (1-365 days) - Future date restrictions When validation fails, the function returns detailed error messages explaining which validations failed and why. These messages can be used to provide clear guidance to users about how to correct their parameter usage. .PARAMETER StartDate The beginning date for the investigation period. Must be provided with EndDate in non-interactive mode. Cannot be later than EndDate or result in a date range exceeding 365 days. .PARAMETER EndDate The ending date for the investigation period. Must be provided with StartDate in non-interactive mode. Cannot be more than one day in the future or result in a date range exceeding 365 days. .PARAMETER DaysToLookBack Alternative to StartDate/EndDate. Specifies the number of days to look back from the current date. Must be between 1 and 365. Cannot be used together with StartDate/EndDate parameters. .PARAMETER FilePath The file system path where investigation results will be stored. Must be a valid file system path. Required in non-interactive mode. .PARAMETER NonInteractive Switch that indicates whether Hawk is running in non-interactive mode. When true, enforces stricter parameter validation requirements. .OUTPUTS PSCustomObject with two properties: - IsValid (bool): Indicates whether all validations passed - ErrorMessages (string[]): Array of error messages when validation fails .EXAMPLE $validation = Test-HawkInvestigationParameter -StartDate "2024-01-01" -EndDate "2024-01-31" -FilePath "C:\Investigation" -NonInteractive Validates parameters for investigating January 2024 in non-interactive mode. The function will verify: - StartDate and EndDate are within valid range - FilePath "C:\Investigation" exists and is valid - Date range does not exceed 365 days - Dates are properly ordered (start before end) .EXAMPLE $validation = Test-HawkInvestigationParameter -DaysToLookBack 30 -FilePath "C:\Investigation" -NonInteractive Validates parameters for a 30-day lookback investigation in non-interactive mode. The function will verify: - DaysToLookBack is between 1 and 365 - FilePath exists and is valid - Calculated date range falls within allowed bounds .EXAMPLE $validation = Test-HawkInvestigationParameter ` -StartDate "2024-01-01" ` -EndDate "2024-01-31" ` -FilePath "C:\Investigation" ` -NonInteractive:$false Validates parameters for an interactive mode investigation of January 2024. In interactive mode, the function applies less stringent validation rules while still ensuring date ranges and paths are valid. .NOTES This is an internal function used by Start-HawkTenantInvestigation and Start-HawkUserInvestigation. It is not intended to be called directly by users of the Hawk module. All datetime operations use UTC internally for consistency. #> [CmdletBinding()] param ( [DateTime]$StartDate, [DateTime]$EndDate, [int]$DaysToLookBack, [string]$FilePath, [switch]$NonInteractive ) # Store validation results $isValid = $true $errorMessages = @() # If in non-interactive mode, validate required parameters if ($NonInteractive) { # Validate FilePath if ([string]::IsNullOrEmpty($FilePath)) { $isValid = $false $errorMessages += "FilePath parameter is required in non-interactive mode" } elseif (-not (Test-Path -Path $FilePath -IsValid)) { $isValid = $false $errorMessages += "Invalid file path provided: $FilePath" } # Validate date parameters if (-not ($StartDate -or $DaysToLookBack)) { $isValid = $false $errorMessages += "Either StartDate or DaysToLookBack must be specified in non-interactive mode" } if ($StartDate -and -not $EndDate) { $isValid = $false $errorMessages += "EndDate must be specified when using StartDate in non-interactive mode" } } # Validate DaysToLookBack regardless of mode if ($DaysToLookBack) { if ($DaysToLookBack -lt 1 -or $DaysToLookBack -gt 365) { $isValid = $false $errorMessages += "DaysToLookBack must be between 1 and 365" } } # Validate date range if both dates provided if ($StartDate -and $EndDate) { # Convert to UTC for consistent comparison $utcStartDate = $StartDate.ToUniversalTime() $utcEndDate = $EndDate.ToUniversalTime() $currentDate = (Get-Date).ToUniversalTime() if ($utcStartDate -gt $utcEndDate) { $isValid = $false $errorMessages += "StartDate must be before EndDate" } # Compare against tomorrow to allow for the extra day $tomorrow = $currentDate.Date.AddDays(1) if ($utcEndDate -gt $tomorrow) { $isValid = $false $errorMessages += "EndDate cannot be more than one day in the future" } # Use dates for day difference calculation # Test against 366 days to account for extra day added for last day inclusiveness $daysDifference = ($utcEndDate.Date - $utcStartDate.Date).Days if ($daysDifference -gt 366) { $isValid = $false $errorMessages += "Date range cannot exceed 365 days" } } # Return validation results [PSCustomObject]@{ IsValid = $isValid ErrorMessages = $errorMessages } } Function Test-HawkNonInteractiveMode { <# .SYNOPSIS Internal function to detect if Hawk should run in non-interactive mode. .DESCRIPTION Tests whether Hawk should operate in non-interactive mode by checking if any initialization parameters (StartDate, EndDate, DaysToLookBack, FilePath, SkipUpdate) were provided at the command line. Non-interactive mode is automatically enabled if any of these parameters are present, removing the need for users to explicitly specify -NonInteractive. .PARAMETER PSBoundParameters The PSBoundParameters hashtable from the calling function. Used to check which parameters were explicitly passed to the parent function. .OUTPUTS [bool] True if any initialization parameters were provided, indicating non-interactive mode. False if no initialization parameters were provided, indicating interactive mode. .EXAMPLE $NonInteractive = Test-HawkNonInteractiveMode -PSBoundParameters $PSBoundParameters Checks the bound parameters to determine if non-interactive mode should be enabled. .NOTES Internal function used by Start-HawkTenantInvestigation and Start-HawkUserInvestigation. #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $true)] [hashtable]$PSBoundParameters ) return $PSBoundParameters.ContainsKey('StartDate') -or $PSBoundParameters.ContainsKey('EndDate') -or $PSBoundParameters.ContainsKey('DaysToLookBack') -or $PSBoundParameters.ContainsKey('FilePath') -or $PSBoundParameters.ContainsKey('SkipUpdate') } function Test-LicenseType { <# .SYNOPSIS Identifies the Microsoft 365 license type (E5/G5, E3/G3, or other) for the current tenant and returns both the license type and corresponding retention period. .DESCRIPTION This function retrieves the list of subscribed SKUs for the tenant using the Microsoft Graph API. It determines the license type based on the `SkuPartNumber` and returns both the license type and appropriate audit log retention period: - E5/G5 licenses (including equivalents): 365 days retention - E3/G3 licenses (including equivalents): 180 days retention - Other/Unknown licenses: 90 days retention .EXAMPLE PS> Test-LicenseType LicenseType RetentionPeriod ----------- --------------- E5 365 Returns E5 license type and 365 days retention period. .EXAMPLE PS> Test-LicenseType LicenseType RetentionPeriod ----------- --------------- G3 180 Returns G3 license type and 180 days retention period. .EXAMPLE PS> Test-LicenseType LicenseType RetentionPeriod ----------- --------------- Unknown 90 Returns Unknown license type and default 90 days retention period. .NOTES Author: Jonathan Butler Last Updated: January 18, 2025 .LINK https://learn.microsoft.com/en-us/powershell/microsoftgraph #> [CmdletBinding()] [OutputType([PSCustomObject])] param() try { # Get tenant subscriptions $subscriptions = Get-MgSubscribedSku # Create custom object to store both license type and retention period $licenseInfo = [PSCustomObject]@{ LicenseType = 'Unknown' RetentionPeriod = 90 } # Check for E5/G5 or equivalent license if ($subscriptions.SkuPartNumber -match 'ENTERPRISEPREMIUM|SPE_E5|DEVELOPERPACK_E5|M365_E5|SPE_G5|ENTERPRISEPREMIUM_GOV|M365_G5|MICROSOFT365_G5') { $licenseInfo.LicenseType = if ($subscriptions.SkuPartNumber -match '_G5|_GOV') { 'G5' } else { 'E5' } $licenseInfo.RetentionPeriod = 365 return $licenseInfo } # Check for E3/G3 or equivalent license if ($subscriptions.SkuPartNumber -match 'ENTERPRISEPACK|M365_E3|DEVELOPERPACK_E3|SPE_G3|ENTERPRISEPACK_GOV|M365_G3|MICROSOFT365_G3') { $licenseInfo.LicenseType = if ($subscriptions.SkuPartNumber -match '_G3|_GOV') { 'G3' } else { 'E3' } $licenseInfo.RetentionPeriod = 180 return $licenseInfo } # Return default values for unknown license type return $licenseInfo } catch { Out-LogFile "Unable to determine license type. Defaulting to 90 days retention." -information return [PSCustomObject]@{ LicenseType = 'Unknown' RetentionPeriod = 90 } } } <# .SYNOPSIS Determine if an IP listed in on the O365 XML list .DESCRIPTION Determine if an IP listed in on the O365 XML list This function uses the System.Net.IPNetwork.dll to parse the IP Addresses. This is the only use for this DLL .PARAMETER IPtoTest IP that is being tested against the Microsoft IP List .PARAMETER Type Checking for ipv 6 or ipv4 .EXAMPLE Test-MicrosoftIP Test wether or not the IP retrieved is a Microsoft IP .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Test-MicrosoftIP { param ( [Parameter(Mandatory = $true)] [string]$IPToTest, [Parameter(Mandatory = $true)] [string]$Type ) # Check if we have imported all of our IP Addresses if ($null -eq $MSFTIPList) { Out-Logfile "Building MSFTIPList" -Action # Load our networking dll pulled from https://github.com/lduchosal/ipnetwork [string]$dll = join-path (Split-path (((get-module Hawk)[0]).path) -Parent) "\bin\System.Net.IPNetwork.dll" $Error.Clear() Out-LogFile ("Loading Networking functions from " + $dll) -Action [Reflection.Assembly]::LoadFile($dll) if ($Error.Count -gt 0) { Out-Logfile "DLL Failed to load can't process IPs" -isError Return "Unknown" } $Error.clear() $MSFTJSON = (Invoke-WebRequest -uri ("https://endpoints.office.com/endpoints/Worldwide?ClientRequestId=" + (new-guid).ToString())).content | ConvertFrom-Json if ($Error.Count -gt 0) { Out-Logfile "Unable to retrieve JSON file" -isError Return "Unknown" } # Make sure our arrays are null [array]$ipv6 = $Null [array]$ipv4 = $Null # Put all of the IP addresses from the JSON into a simple array Foreach ($Entry in $MSFTJSON) { $IPList += $Entry.IPs } # Throw out duplicates $IPList = $IPList | Select-Object -Unique # Add the IP Addresses into either the v4 or v6 arrays Foreach ($ip in $IPList) { if ($ip -like "*.*") { $ipv4 += $ip } else { $ipv6 += $ip } } Out-LogFile ("Found " + $ipv6.Count + " unique MSFT IPv6 address ranges") -Information Out-LogFile ("Found " + $ipv4.count + " unique MSFT IPv4 address ranges") -Information # New up using our networking dll we need to pull these all in as network objects foreach ($ip in $ipv6) { [array]$ipv6objects += [System.Net.IPNetwork]::Parse($ip) } foreach ($ip in $ipv4) { [array]$ipv4objects += [System.Net.IPNetwork]::Parse($ip) } # Now create our output object $output = $Null $output = New-Object -TypeName PSObject $output | Add-Member -MemberType NoteProperty -Value $ipv6objects -Name IPv6Objects $output | Add-Member -MemberType NoteProperty -Value $ipv4objects -Name IPv4Objects # Create a global variable to hold our IP list so we can keep using it Out-LogFile "Creating global variable `$MSFTIPList" -Action New-Variable -Name MSFTIPList -Value $output -Scope global } # Determine if we have an ipv6 or ipv4 address if ($Type -like "ipv6") { # Compare to the IPv6 list [int]$i = 0 [int]$count = $MSFTIPList.ipv6objects.count - 1 # Compare each IP to the ip networks to see if it is in that network # If we get back a True or we are beyond the end of the list then stop do { # Test the IP $parsedip = [System.Net.IPAddress]::Parse($IPToTest) $test = [System.Net.IPNetwork]::Contains($MSFTIPList.ipv6objects[$i], $parsedip) $i++ } until(($test -eq $true) -or ($i -gt $count)) # Return the value of test true = in MSFT network Return $test } else { # Compare to the IPv4 list [int]$i = 0 [int]$count = $MSFTIPList.ipv4objects.count - 1 # Compare each IP to the ip networks to see if it is in that network # If we get back a True or we are beyond the end of the list then stop do { # Test the IP $parsedip = [System.Net.IPAddress]::Parse($IPToTest) $test = [System.Net.IPNetwork]::Contains($MSFTIPList.ipv4objects[$i], $parsedip) $i++ } until(($test -eq $true) -or ($i -gt $count)) # Return the value of test true = in MSFT network Return $test } } Function Test-OperationEnabled { <# .SYNOPSIS Tests if a specified audit operation is enabled for a given user. .DESCRIPTION An internal helper function that verifies whether a specific audit operation is enabled in a user's mailbox auditing configuration. This function queries the mailbox settings using Get-Mailbox and checks the AuditOwner property for the specified operation. .PARAMETER User The UserPrincipalName of the user to check auditing configuration for. .PARAMETER Operation The specific audit operation to check for (e.g., 'SearchQueryInitiated'). .EXAMPLE $result = Test-OperationEnabled -User "user@contoso.com" -Operation "SearchQueryInitiated" Checks if the SearchQueryInitiated operation is enabled for user@contoso.com's mailbox. Returns True if enabled, False if not enabled. .EXAMPLE if (Test-OperationEnabled -User $userUpn -Operation 'MailItemsAccessed') { # Proceed with mail items access audit } Shows how to use the function in a conditional check before performing an audit operation that requires specific permissions. .OUTPUTS System.Boolean Returns True if the operation is enabled for the user, False otherwise. .NOTES Internal Function Author: Jonathan Butler Requirements: Exchange Online PowerShell session with appropriate permissions #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory=$true)] [string]$User, [Parameter(Mandatory=$true)] [string]$Operation ) # Verify the provided User has the specified Operation enabled $TestResult = Get-Mailbox -Identity $User | Where-Object -Property AuditOwner -eq $Operation if ($null -eq $TestResult) { return $false } else { return $true } } Function Test-RecipientAge { <# .SYNOPSIS Check to see if a recipient object was created since our start date .DESCRIPTION Check to see if a recipient object was created since our start date. This will be used to determine if a new user has been created within the time frame specified. .PARAMETER RecipientID Recipient object ID that is being retrieved .EXAMPLE Test-RecipientAge Will test to see if the recipient object was created since the start date .NOTES General notes #> Param([string]$RecipientID) $recipient = Get-Recipient -Identity $RecipientID -erroraction SilentlyContinue # Verify that we got something back if ($null -eq $recipient) { Return 2 } # If the date created is newer than our StartDate return non zero (1) elseif ($recipient.whencreated -gt $Hawk.StartDate) { Return 1 } # If it is older than the start date return 0 else { Return 0 } } Function Test-SuspiciousInboxRule { <# .SYNOPSIS Internal helper function to detect suspicious inbox rule patterns. .DESCRIPTION Analyzes inbox rule properties to identify potentially suspicious configurations like external forwarding, message deletion, or targeting of security-related content. Used by both rule creation and modification audit functions. .PARAMETER Rule The parsed inbox rule object to analyze. .PARAMETER Reasons [ref] array to store the reasons why a rule was flagged as suspicious. .OUTPUTS Boolean indicating if the rule matches suspicious patterns. Populates the Reasons array parameter with explanations if suspicious. .EXAMPLE $reasons = @() $isSuspicious = Test-SuspiciousInboxRule -Rule $ruleObject -Reasons ([ref]$reasons) #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory = $true)] [object]$Rule, [Parameter(Mandatory = $true)] [ref]$Reasons ) $isSuspicious = $false $suspiciousReasons = @() # Check forwarding/redirection configurations if ($Rule.Param_ForwardTo) { $isSuspicious = $true $suspiciousReasons += "forwards to: $($Rule.Param_ForwardTo)" } if ($Rule.Param_ForwardAsAttachmentTo) { $isSuspicious = $true $suspiciousReasons += "forwards as attachment to: $($Rule.Param_ForwardAsAttachmentTo)" } if ($Rule.Param_RedirectTo) { $isSuspicious = $true $suspiciousReasons += "redirects to: $($Rule.Param_RedirectTo)" } # Check deletion/move to deleted items if ($Rule.Param_DeleteMessage) { $isSuspicious = $true $suspiciousReasons += "deletes messages" } if ($Rule.Param_MoveToFolder -eq 'Deleted Items') { $isSuspicious = $true $suspiciousReasons += "moves to Deleted Items" } # Update the reasons array with our findings $Reasons.Value = $suspiciousReasons return $isSuspicious } <# .SYNOPSIS Determine if we have an array with UPNs or just a single UPN / UPN array unlabeled .DESCRIPTION Determine if we have an array with UPNs or just a single UPN / UPN array unlabeled .PARAMETER ToTest Object which is being tested .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Test-UserObject { param ([array]$ToTest) # So we take three inputs here to -userprincipalname string,array,and array of strings # We need to test the input value and make sure that that are in a form that the Function can understand # The function needs them as an array of object with a property of .UserPrincipalName #Case 1 - String #Case 2 - Array of Strings #Check to see if the value of the entry is of type string if ($ToTest[0] -is [string]) { # Very basic check to see if this is a UPN if ($ToTest[0] -match '@') { [array]$Output = $ToTest | Select-Object -Property @{Name = "UserPrincipalName"; Expression = { $_ } } Return $Output } else { Out-LogFile "Unable to determine if input is a UserPrincipalName" -isError Out-LogFile "Please provide a UPN or array of objects with propertly UserPrincipalName populated" -Information Write-Error "Unable to determine if input is a User Principal Name" -ErrorAction Stop } } # Case 3 - Array of objects # Validate that at least one object in the array contains a UserPrincipalName Property elseif ([bool](get-member -inputobject $ToTest[0] -name UserPrincipalName -MemberType Properties)) { Return $ToTest } else { Out-LogFile "Unable to determine if input is a UserPrincipalName" -isError Out-LogFile "Please provide a UPN or array of objects with propertly UserPrincipalName populated" -Information Write-Error "Unable to determine if input is a User Principal Name" -ErrorAction Stop } } Function Write-HawkBanner { <# .SYNOPSIS Displays the Hawk welcome banner in the terminal. .DESCRIPTION The `Write-HawkBanner` function displays a visually appealing ASCII art banner when starting Hawk operations. The banner includes the Hawk logo and additional information about the tool. Optionally, the function can display a welcome message to guide users through the initial setup process. .PARAMETER DisplayWelcomeMessage This optional switch parameter displays a series of informational messages to help the user configure their investigation environment. .INPUTS None. The function does not take pipeline input. .OUTPUTS [String] The function outputs the Hawk banner as a string to the terminal. .EXAMPLE Write-HawkBanner Displays the Hawk welcome banner without the welcome message. .EXAMPLE Write-HawkBanner -DisplayWelcomeMessage Displays the Hawk welcome banner followed by a welcome message that guides the user through configuring the investigation environment. #> [CmdletBinding()] param( [Switch]$DisplayWelcomeMessage ) $banner = @' ======================================== __ __ __ / / / /___ __ __/ /__ / /_/ / __ `/ | /| / / //_/ / __ / /_/ /| |/ |/ / ,< /_/ /_/\__,_/ |__/|__/_/|_| ======================================== Microsoft Cloud Security Analysis Tool https://hawkforensics.io ======================================== '@ Write-Output $banner if ($DisplayWelcomeMessage) { Write-Information "Welcome to Hawk! Let's get your investigation environment set up." Write-Information "We'll guide you through configuring the output file path and investigation date range." Write-Information "You'll need to specify where logs should be saved and the time window for data retrieval." Write-Information "If you're unsure, don't worry! Default options will be provided to help you out." Write-Information "`nLet's get started!`n" } } Function Write-HawkConfigurationComplete { <# .SYNOPSIS Displays the completed Hawk configuration settings. .DESCRIPTION Outputs a summary of all configured Hawk settings after initialization is complete. This includes version information and all properties of the Hawk configuration object, formatted for easy reading. Null or empty values are displayed as "N/A". .PARAMETER Hawk A PSCustomObject containing the Hawk configuration settings. This object must include properties for FilePath, DaysToLookBack, StartDate, EndDate, and other required configuration values. .EXAMPLE PS C:\> Write-HawkConfigurationComplete -Hawk $Hawk Displays the complete Hawk configuration settings from the provided Hawk object, including file paths, date ranges, and version information. .EXAMPLE PS C:\> $config = Initialize-HawkGlobalObject PS C:\> Write-HawkConfigurationComplete -Hawk $config Initializes a new Hawk configuration and displays the complete settings. .NOTES This function is typically called automatically after Hawk initialization but can be run manually to review current settings. #> [CmdletBinding()] param ( [Parameter( Mandatory = $true, Position = 0, ValueFromPipeline = $true, HelpMessage = "PSCustomObject containing Hawk configuration settings" )] [PSCustomObject]$Hawk ) process { Write-Output "" Out-LogFile "====================================================================" -Information Out-LogFile "Configuration Complete!" -Information Out-LogFile "Your Hawk environment is now set up with the following settings:" -Information Out-LogFile ("Hawk Version: " + (Get-Module Hawk).version) -Information # Get properties excluding the ones we don't want to display $properties = $Hawk.PSObject.Properties | Where-Object { $_.Name -notin @('DaysToLookBack', 'WhenCreated') } # Format property names and create array of formatted names $formattedNames = @() foreach ($prop in $properties) { $name = $prop.Name -creplace '([A-Z])', ' $1' -replace '_', ' ' $formattedNames += $name.Trim() } # Find the longest property name $maxLength = ($formattedNames | Measure-Object -Property Length -Maximum).Maximum # Output each property with consistent alignment for ($i = 0; $i -lt $properties.Count; $i++) { $prop = $properties[$i] $formattedName = $formattedNames[$i].PadRight($maxLength) # Get value with N/A fallback $value = if ($null -eq $prop.Value -or [string]::IsNullOrEmpty($prop.Value.ToString())) { "N/A" } else { $prop.Value } Out-LogFile ("{0} : {1}" -f $formattedName, $value) -Information } Out-LogFile "`Happy Hunting! 🦅" -Information Out-LogFile "====================================================================" -Information Write-Output "" } } function Write-HawkInvestigationSummary { <# .SYNOPSIS Outputs a summary of a Hawk investigation session. .DESCRIPTION Creates and displays a summary report of a Hawk investigation session, including the time range, investigation type, and if applicable, the users investigated. This summary helps track and document investigation scope and parameters. .PARAMETER StartTime The UTC start time of the investigation period. .PARAMETER EndTime The UTC end time of the investigation period. .PARAMETER InvestigationType The type of investigation performed. Valid values include "Tenant" and "User". .PARAMETER UserPrincipalName For user investigations, an array of user principal names that were investigated. Not required for tenant-wide investigations. .EXAMPLE PS C:\> Write-HawkInvestigationSummary -StartTime "2024-01-01" -EndTime "2024-01-31" -InvestigationType "Tenant" Outputs a summary of a tenant-wide investigation covering January 2024. .EXAMPLE PS C:\> Write-HawkInvestigationSummary -StartTime "2024-01-01" -EndTime "2024-01-31" -InvestigationType "User" -UserPrincipalName "user@contoso.com" Outputs a summary of a user investigation for a specific user during January 2024. .NOTES This function should be called at the end of investigation sessions to document the scope and parameters of the investigation. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [DateTime]$StartTime, [Parameter(Mandatory = $true)] [DateTime]$EndTime, [Parameter(Mandatory = $true)] [ValidateSet('User', 'Tenant')] [string]$InvestigationType, [Parameter()] [array]$UserPrincipalName ) # Calculate total duration $duration = $EndTime - $StartTime # Create a more readable duration string with labels $durationParts = @() if ($duration.Hours -gt 0) { $durationParts += "{0} hours" -f $duration.Hours } if ($duration.Minutes -gt 0) { $durationParts += "{0} minutes" -f $duration.Minutes } if ($duration.Seconds -gt 0 -or $durationParts.Count -eq 0) { $durationParts += "{0} seconds" -f $duration.Seconds } $durationStr = $durationParts -join ", " Write-Output "" Out-LogFile "=========================================================================" -Information # Output different message based on investigation type if ($InvestigationType -eq 'Tenant') { Out-LogFile "Tenant Investigation complete for tenant: $($Hawk.TenantName)" -Information } else { # Handle user investigation output if ($UserPrincipalName.Count -eq 1) { # Single user case if ($UserPrincipalName[0] -is [PSCustomObject]) { $upn = $UserPrincipalName[0].UserPrincipalName } else { $upn = $UserPrincipalName[0] } Out-LogFile "User Investigation complete for user: '$upn'" -Information } else { # Multiple users case Out-LogFile "User Investigation complete for users:" -Information foreach ($user in $UserPrincipalName) { if ($user -is [PSCustomObject]) { $upn = $user.UserPrincipalName } else { $upn = $user } Out-LogFile "* $upn" -Information } } } Out-LogFile "Total run time: $durationStr" -Information Out-LogFile "Please review investigation files at: $($Hawk.FilePath)" -Information # Only show the additional investigation message for tenant investigations if ($InvestigationType -eq 'Tenant') { Out-LogFile "To investigate specific users, run: Start-HawkUserInvestigation" -Information } Out-LogFile "=========================================================================" -Information Write-Output "" } <# .SYNOPSIS Show Hawk Help and creates the Hawk_Help.txt file .DESCRIPTION Show Hawk Help and creates the Hawk_Help.txt file .EXAMPLE PS C:\> <example usage> Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS Output (if any) .NOTES General notes #> Function Show-HawkHelp { Out-LogFile "Creating Hawk Help File" $help = "BASIC USAGE INFORMATION FOR THE HAWK MODULE =========================================== Hawk is in constant development. We will be adding addtional data gathering and information analysis. DISCLAIMER: =========================================== THE SAMPLE SCRIPTS ARE NOT SUPPORTED UNDER ANY MICROSOFT STANDARD SUPPORT PROGRAM OR SERVICE. THE SAMPLE SCRIPTS ARE PROVIDED AS IS WITHOUT WARRANTY OF ANY KIND. MICROSOFT FURTHER DISCLAIMS ALL IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR OF FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK ARISING OUT OF THE USE OR PERFORMANCE OF THE SAMPLE SCRIPTS AND DOCUMENTATION REMAINS WITH YOU. IN NO EVENT SHALL MICROSOFT, ITS AUTHORS, OR ANYONE ELSE INVOLVED IN THE CREATION, PRODUCTION, OR DELIVERY OF THE SCRIPTS BE LIABLE FOR ANY DAMAGES WHATSOEVER (INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF BUSINESS PROFITS, BUSINESS INTERRUPTION, LOSS OF BUSINESS INFORMATION, OR OTHER PECUNIARY LOSS) ARISING OUT OF THE USE OF OR INABILITY TO USE THE SAMPLE SCRIPTS OR DOCUMENTATION, EVEN IF MICROSOFT HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES PURPOSE: =========================================== The Hawk module has been designed to ease the burden on O365 administrators who are performing a forensic analysis in their organization. It does NOT take the place of a human reviewing the data generated and is simply here to make data gathering easier. HOW TO USE: =========================================== Hawk is divided into two primary forms of cmdlets; user based Cmdlets and Tenant based cmdlets. User based cmdlets take the form Verb-HawkUser<action>. They all expect a -user switch and will retrieve information specific to the user that is specified. Tenant based cmdlets take the form Verb-HawkTenant<Action>. They don't need any switches and will return information about the whole tenant. A good starting place is the Start-HawkTenantInvestigation this will run all the tenant based cmdlets and provide a collection of data to start with. Once this data has been reviewed if there are specific user(s) that more information should be gathered on Start-HawkUserInvestigation will gather all the User specific information for a single user. All Hawk cmdlets include help that provides an overview of the data they gather and a listing of all possible output files. Run Get-Help <cmdlet> -full to see the full help output for a given Hawk cmdlet. Some of the Hawk cmdlets will flag results that should be further reviewed. These will appear in _Investigate files. These are NOT indicative of unwanted activity but are simply things that should reviewed. REVIEW HAWK CODE: =========================================== The Hawk module is written in PowerShell and only uses cmdlets and function that are availble to all O365 customers. Since it is written in PowerShell anyone who has downloaded it can and is encouraged to review the code so that they have a clear understanding of what it is doing and are comfortable with it prior to running it in their environment. To view the code in notepad run the following command in powershell: notepad (join-path ((get-module hawk -ListAvailable)[0]).modulebase 'Hawk.psm1') To get the path for the module for use in other application run: ((Get-module Hawk -listavailable)[0]).modulebase" $help | Out-MultipleFileType -FilePrefix "Hawk_Help" -txt Notepad (Join-Path $hawk.filepath "Tenant\Hawk_Help.txt") } Function Update-HawkModule { <# .SYNOPSIS Hawk upgrade check. .DESCRIPTION Hawk upgrade check. Checks the PowerShell Gallery for newer versions of the Hawk module and handles the update process, including elevation of privileges if needed. .PARAMETER ElevatedUpdate Switch parameter indicating the function is running in an elevated context. .PARAMETER WhatIf Shows what would happen if the command runs. The command is not run. .PARAMETER Confirm Prompts you for confirmation before running the command. .EXAMPLE Update-HawkModule Checks for update to Hawk Module on PowerShell Gallery. .NOTES Requires elevation to Administrator rights to perform the update. #> [CmdletBinding(SupportsShouldProcess)] param ( [switch]$ElevatedUpdate ) # If ElevatedUpdate is true then we are running from a forced elevation and we just need to run without prompting if ($ElevatedUpdate) { # Set upgrade to true $Upgrade = $true } else { # See if we can do an upgrade check if ($null -eq (Get-Command Find-Module)) { } # If we can then look for an updated version of the module else { Out-LogFile "Checking for latest version online" -Action $onlineversion = Find-Module -name Hawk -erroraction silentlycontinue $Localversion = (Get-Module Hawk | Sort-Object -Property Version -Descending)[0] Out-LogFile ("Found Version " + $onlineversion.version + " Online") -Information if ($null -eq $onlineversion){ Out-LogFile "[ERROR] - Unable to check Hawk version in Gallery" -isError } elseif (([version]$onlineversion.version) -gt ([version]$localversion.version)) { Out-LogFile "New version of Hawk module found online" -Information Out-LogFile ("Local Version: " + $localversion.version + " Online Version: " + $onlineversion.version) -Information # Prompt the user to upgrade or not $title = "Upgrade version" $message = "A Newer version of the Hawk Module has been found Online. `nUpgrade to latest version?" $Yes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Stops the function and provides directions for upgrading." $No = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Continues running current function" $options = [System.Management.Automation.Host.ChoiceDescription[]]($Yes, $No) $result = $host.ui.PromptForChoice($title, $message, $options, 0) # Check to see what the user choose switch ($result) { 0 { $Upgrade = $true; Send-AIEvent -Event Upgrade -Properties @{"Upgrade" = "True" } } 1 { $Upgrade = $false; Send-AIEvent -Event Upgrade -Properties @{"Upgrade" = "False" } } } } # If the versions match then we don't need to upgrade else { Out-LogFile "Latest Version Installed" -Information } } } # If we determined that we want to do an upgrade make the needed checks and do it if ($Upgrade) { # Determine if we have an elevated powershell prompt If (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { # Update the module if ($PSCmdlet.ShouldProcess("Hawk Module", "Update module")) { Out-LogFile "Downloading Updated Hawk Module" -Action Update-Module Hawk -Force Out-LogFile "Update Finished" -Action Start-Sleep 3 # If Elevated update then this prompt was created by the Update-HawkModule function and we can close it out otherwise leave it up if ($ElevatedUpdate) { exit } # If we didn't elevate then we are running in the admin prompt and we need to import the new hawk module else { Out-LogFile "Starting new PowerShell Window with the updated Hawk Module loaded" -Action # We can't load a new copy of the same module from inside the module so we have to start a new window Start-Process powershell.exe -ArgumentList "-noexit -Command Import-Module Hawk -force" -Verb RunAs Out-LogFile "Updated Hawk Module loaded in New PowerShell Window. Please Close this Window." -Notice break } } } # If we are not running as admin we need to start an admin prompt else { # Relaunch as an elevated process: Out-LogFile "Starting Elevated Prompt" -Action Start-Process powershell.exe -ArgumentList "-noexit -Command Import-Module Hawk;Update-HawkModule -ElevatedUpdate" -Verb RunAs -Wait Out-LogFile "Starting new PowerShell Window with the updated Hawk Module loaded" -Action # We can't load a new copy of the same module from inside the module so we have to start a new window Start-Process powershell.exe -ArgumentList "-noexit -Command Import-Module Hawk -force" Out-LogFile "Updated Hawk Module loaded in New PowerShell Window. Please Close this Window." -Notice break } } # Since upgrade is false we log and continue else { Out-LogFile "Skipping Upgrade" -Action } } Function Get-HawkMessageHeader { <# .SYNOPSIS Gathers the header from the an msg file prepares a report .DESCRIPTION Gathers the header from the an msg file prepares a report For Best Results: * Capture a message which was sent from the bad actor to an internal user. * Get a copy of the message from the internal user's mailbox. * For transfering the file ensure that the source msg is zipped before emailing. * On Recieve the admin should extract the MSG and run this cmdlet against it. .PARAMETER MSGFile Path to an export MSG file. .OUTPUTS File: Message_Header.csv Path: \<message name> Description: Message Header in CSV form File: Message_Header_RAW.txt Path: \<message name> Description: Raw header sutible for going into other tools .EXAMPLE Get-HawkMessageHeader -msgfile 'c:\temp\my suspicious message.msg' Pulls the header and reviews critical information #> param ( [Parameter(Mandatory = $true)] [string]$MSGFile ) # Create the outlook com object try { $ol = New-Object -ComObject Outlook.Application } catch [System.Runtime.InteropServices.COMException] { # If we throw a com expection most likely reason is outlook isn't installed Out-LogFile "Unable to create outlook com object." -error Out-LogFile "Please make sure outlook is installed." -error Out-LogFile $Error[0] Write-Error "Unable to create Outlook Com Object, please ensure outlook is installed" -ErrorAction Stop } # Create the Hawk object if it isn't there already Initialize-HawkGlobalObject Send-AIEvent -Event "CmdRun" # check to see if we have a valid file path if (Test-Path $MSGFile) { # Convert a possible relative path to a full path $MSGFile = (Resolve-Path $MSGFile).Path # Store the file name for later use $MSGFileName = $MSGFile | Split-Path -Leaf Out-LogFile ("Reading message header from file " + $MSGFile) -action # Import the message and start processing the header try { $msg = $ol.CreateItemFromTemplate($MSGFile) $header = $msg.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x007D001E") } catch { Out-LogFile ("Unable to load " + $MSGFile) Out-LogFile $Error[0] break } $headersWithLines = $header.split("`n") } else { # If we don't have a valid file path log an error and stop Out-LogFile ("Failed to find file " + $MSGFile) -error Write-Error -Message "Failed to find file " + $MSGFile -ErrorAction Stop } # Make sure variables are empty [string]$CombinedString = $null [array]$Output = $null # Read thru each line to pull together each entry into a single object foreach ($string in $headersWithLines) { # If our string is not null and we have a leading whitespace then this needs to be added to the previous string as part of the same object. if (!([string]::IsNullOrEmpty($string)) -and ([char]::IsWhiteSpace($string[0]))) { # Do some string clean up $string = $string.trimstart() $string = $string.trimend() $string = " " + $string # Push the string together [string]$CombinedString += $string } # If we are here we do a null check just in case but we know the first char is not a whitespace # So we have a new "object" that we need to process in elseif (!([string]::IsNullOrEmpty($string))) { # For the inital pass the string will be null or empty so we need to check for that if ([string]::IsNullOrEmpty($CombinedString)) { # Create our new string and continue processing $CombinedString = ($string.trimend()) } else { # We should have everything now so create the object $Object = $null $Object = New-Object -TypeName PSObject # Split the string on the divider and add it to the object [array]$StringSplit = $CombinedString -split ":", 2 $Object | Add-Member -MemberType NoteProperty -Name "Header" -Value $StringSplit[0].trim() $Object | Add-Member -MemberType NoteProperty -Name "Value" -Value $StringSplit[1].trim() # Add to the output array [array]$Output += $Object # Create our new string and continue processing $CombinedString = $string.trimend() } } else { } } # Now that we have the header objects in an array we can work on them and output a report $receivedHeadersString = $null $receivedHeadersObject = $null # Null out the output [array]$Findings = $null # Determine the initial submitting client/ip [array]$receivedHeadersString = $Output | Where-Object { $_.header -eq "Received" } foreach ($stringHeader in $receivedHeadersString.value) { [array]$receivedHeadersObject += Convert-ReceiveHeader -Header $stringHeader } # Sort the receive header so oldest is at the top $receivedHeadersObject = $receivedHeadersObject | Sort-Object -Property ReceivedFromTime if ($null -eq $receivedHeadersObject) { } else { # Determine how it was submitted to the service if ($receivedHeadersObject[0].ReceivedBy -like "*outlook.com*") { $Findings += (Add-Finding -Name "Submitting Host" -Value $receivedHeadersObject[0].ReceivedBy -Conclusion "Submitted from Office 365" -MoreInformation "Warning - This might have originated from one of your clients") } else { $Findings += (Add-Finding -Name "Submitting Host" -Value $receivedHeadersObject[0].ReceivedBy -Conclusion "Submitted from Internet" -MoreInformation "") } ### Output to the report the client that submitted $Findings += (Add-Finding -Name "Submitting Client" -Value $receivedHeadersObject[0].ReceivedWith -Conclusion "None" -MoreInformation "") } ### Output the AuthAS type $AuthAs = $output | Where-Object { $_.header -like 'X-MS-Exchange-Organization-AuthAs' } # Make sure we got something back if ($null -eq $AuthAs) { } else { # If auth is anonymous then it came from the internet if ($AuthAs.value -eq "Anonymous") { $Findings += (Add-Finding -Name "Authentication Method" -Value $AuthAs.value -Conclusion "Method used to authenticate" -MoreInformation "https://docs.microsoft.com/en-us/exchange/header-firewall-exchange-2013-help") } else { $Findings += (Add-Finding -Name "Authentication Method" -Value $AuthAs.value -Conclusion "Method used to authenticate" -MoreInformation "https://docs.microsoft.com/en-us/exchange/header-firewall-exchange-2013-help") } } ### Determine the AuthMechanism $AuthMech = $output | Where-Object { $_.header -like 'X-MS-Exchange-Organization-AuthMechanism' } # Make sure we got something back if ($null -eq $AuthMech) { } else { # If auth is anonymous then it came from the internet if ($AuthMech.value -eq "04" -or $AuthMech.value -eq "06") { $Findings += (Add-Finding -Name "Authentication Mechanism" -Value $AuthMech.value -Conclusion "04 = Credentials Used; 06 = SMTP Authentication" -MoreInformation "https://docs.microsoft.com/en-us/exchange/header-firewall-exchange-2013-help") } else { $Findings += (Add-Finding -Name "Authentication Mechanism" -Value $AuthMech.value -Conclusion "Mechanism used to authenticate" -MoreInformation "https://docs.microsoft.com/en-us/exchange/header-firewall-exchange-2013-help") } } ### Do P1 and P2 match $From = $output | Where-Object { $_.header -like 'From' } $ReturnPath = $output | Where-Object { $_.header -like 'Return-Path' } # Pull out the from string since it can be formatted with a name $frommatches = $null $frommatches = $From.Value | Select-String -Pattern '(?<=<)([\s\S]*?)(?=>)' -AllMatches if ($null -ne $frommatches) { # Pull the string from the matches [string]$fromString = $frommatches.Matches.Groups[1].Value } else { [string]$fromString = $From.value } # Check to see if they match if ($fromString.trim() -eq $ReturnPath.value.trim()) { $Findings += (Add-Finding -Name "P1 P2 Match" -Value ("From: " + $From.value + "; Return-Path: " + $ReturnPath.value) -Conclusion "P1 and P2 Header match" -MoreInformation "") } else { $Findings += (Add-Finding -Name "P1 P2 Match" -Value ("From: " + $From.value + "; Return-Path: " + $ReturnPath.value) -Conclusion "P1 and P2 Header don't Match" -MoreInformation "WARNING - P1 and P2 Header don't Match") } # Output the Findings $Findings | Out-MultipleFileType -FilePrefix "Message_Header_Findings" -user $MSGFileName -csv # Output everything to a file $Output | Out-MultipleFileType -FilePrefix "Message_Header" -User $MSGFileName -csv # Output the RAW Header to the file for use in other tools $header | Out-MultipleFileType -FilePrefix "Message_Header_RAW" -user $MSGFileName -txt } # Function to create a finding object for adding to the output array Function Add-Finding { param ( [string]$Name, [string]$Value, [string]$Conclusion, [string]$MoreInformation ) # Create the object $Obj = New-Object PSObject # Added the needed properties $Obj | Add-Member -MemberType NoteProperty -Name "Rule" -Value $Name $Obj | Add-Member -MemberType NoteProperty -Name "Value" -Value $Value $Obj | Add-Member -MemberType NoteProperty -Name "Conclusion" -Value $Conclusion $Obj | Add-Member -MemberType NoteProperty -Name "More Information" -Value $MoreInformation # Return the object Return $Obj } # Processing a received header and returns it as a object Function Convert-ReceiveHeader { #Core code from https://blogs.technet.microsoft.com/heyscriptingguy/2011/08/18/use-powershell-to-parse-email-message-headerspart-1/ Param ( [Parameter(Mandatory = $true)] [String]$Header ) # Remove any leading spaces from the input text $Header = $Header.TrimStart() $Header = $Header + " " # Create our regular expression for pulling out the sections of the header $HeaderRegex = 'from([\s\S]*?)by([\s\S]*?)with([\s\S]*?);([(\s\S)*]{32,36})(?:\s\S*?)' # Find out different groups with the regex $headerMatches = $Header | Select-String -Pattern $HeaderRegex -AllMatches # Check if we got back results if ($null -ne $headerMatches) { # Formatch our with Switch -wildcard ($headerMatches.Matches.groups[3].value.trim()) { "SMTP*" { $with = "SMTP" } "ESMTP*" { $with = "ESMTP" } default { $with = $headerMatches.Matches.groups[3].value.trim() } } # Create the hash to generate the output object $fromhash = @{ ReceivedFrom = $headerMatches.Matches.groups[1].value.trim() ReceivedBy = $headerMatches.Matches.groups[2].value.trim() ReceivedWith = $with ReceivedTime = [datetime]($headerMatches.Matches.groups[4].value.trim()) } # Put the data into an object and return it $Output = New-Object -TypeName PSObject -Property $fromhash return $Output } # If we failed to match then return null else { return $null } } Function Get-HawkTenantAdminEmailForwardingChange { <# .SYNOPSIS Retrieves audit log entries for email forwarding changes made within the tenant. .DESCRIPTION This function queries the Microsoft 365 Unified Audit Log for events related to email forwarding configuration changes (Set-Mailbox with forwarding parameters). It focuses on tracking when and by whom forwarding rules were added or modified, helping identify potential unauthorized data exfiltration attempts. Key points: - Monitors changes to both ForwardingAddress and ForwardingSMTPAddress settings - Resolves recipient information for ForwardingAddress values - Flags all forwarding changes for review as potential security concerns - Provides historical context for forwarding configuration changes .OUTPUTS File: Simple_Forwarding_Changes.csv/.json Path: \Tenant Description: Simplified view of forwarding configuration changes. File: Forwarding_Changes.csv/.json Path: \Tenant Description: Detailed audit log data for forwarding changes. File: Forwarding_Recipients.csv/.json Path: \Tenant Description: List of unique forwarding destinations configured. .EXAMPLE Get-HawkTenantAdminEmailForwardingChange Retrieves all email forwarding configuration changes from the audit logs within the specified search window. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of email forwarding configuration changes from the UAL." -Action # Test the Exchange Online connection to ensure the environment is ready for operations. Test-EXOConnection # Log the execution of the function for audit and telemetry purposes. Send-AIEvent -Event "CmdRun" # Initialize timing variables for status updates $startTime = Get-Date $lastUpdate = $startTime # Log the start of the analysis process for email forwarding configuration changes. Out-LogFile "Collecting email forwarding configuration changes from the UAL." -Action # Ensure the tenant-specific folder exists to store output files. If not, create it. $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Define both operations and broader search terms to cast a wider net. $searchCommand = @" Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations @( 'Set-Mailbox', 'Set-MailUser', 'Set-RemoteMailbox', 'Enable-RemoteMailbox' ) "@ # Fetch all specified operations from the audit log [array]$AllMailboxChanges = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand # Log search completion time Out-LogFile "Completed collection of email forwarding configuration changes from the UAL." -Information Out-LogFile "Filtering results for forwarding changes..." -Action # Enhanced filtering to catch more types of forwarding changes [array]$ForwardingChanges = $AllMailboxChanges | Where-Object { $auditData = $_.AuditData | ConvertFrom-Json $parameters = $auditData.Parameters ($parameters | Where-Object { $_.Name -in @( 'ForwardingAddress', 'ForwardingSMTPAddress', 'ExternalEmailAddress', 'PrimarySmtpAddress', 'RedirectTo', 'DeliverToMailboxAndForward', # Corrected parameter name 'DeliverToAndForward' # Alternative parameter name ) -or # Check for parameter changes enabling forwarding ($_.Name -eq 'DeliverToMailboxAndForward' -and $_.Value -eq 'True') -or ($_.Name -eq 'DeliverToAndForward' -and $_.Value -eq 'True') }) } Out-LogFile "Completed filtering for forwarding changes." -Information if ($ForwardingChanges.Count -gt 0) { # Log the number of forwarding configuration changes found. Out-LogFile ("Found " + $ForwardingChanges.Count + " change(s) to user email forwarding.") -Information # Parse the audit data into a simpler format for further processing and output. $ParsedChanges = $ForwardingChanges | Get-SimpleUnifiedAuditLog if ($ParsedChanges) { # Write the simplified data for quick analysis and review. $ParsedChanges | Out-MultipleFileType -FilePrefix "Simple_Forwarding_Changes" -csv -json -Notice # Write the full audit log data for comprehensive records. $ForwardingChanges | Out-MultipleFileType -FilePrefix "Forwarding_Changes" -csv -json -Notice # Initialize an array to store processed forwarding destination data. $ForwardingDestinations = @() Out-LogFile "Beginning detailed analysis of forwarding changes..." -Action foreach ($change in $ParsedChanges) { # Add a status update every 30 seconds $currentTime = Get-Date if (($currentTime - $lastUpdate).TotalSeconds -ge 30) { Out-LogFile "Processing forwarding changes... ($($ForwardingDestinations.Count) destinations found so far)." -Action $lastUpdate = $currentTime } $targetUser = $change.ObjectId # Process ForwardingSMTPAddress changes if detected in the audit log. if ($change.Parameters -match "ForwardingSMTPAddress") { $smtpAddress = ($change.Parameters | Select-String -Pattern "ForwardingSMTPAddress:\s*([^,]+)").Matches.Groups[1].Value if ($smtpAddress) { # Add the SMTP forwarding configuration to the destinations array. $ForwardingDestinations += [PSCustomObject]@{ UserModified = $targetUser TargetSMTPAddress = $smtpAddress.Split(":")[-1].Trim() # Remove "SMTP:" prefix if present. ChangeType = "SMTP Forwarding" ModifiedBy = $change.UserId ModifiedTime = $change.CreationTime } } } # Process ForwardingAddress changes if detected in the audit log. if ($change.Parameters -match "ForwardingAddress") { $forwardingAddress = ($change.Parameters | Select-String -Pattern "ForwardingAddress:\s*([^,]+)").Matches.Groups[1].Value if ($forwardingAddress) { try { # Attempt to resolve the recipient details from Exchange Online. $recipient = Get-EXORecipient $forwardingAddress -ErrorAction Stop # Determine the recipient's type and extract the appropriate address. $targetAddress = switch ($recipient.RecipientType) { "MailContact" { $recipient.ExternalEmailAddress.Split(":")[-1] } default { $recipient.PrimarySmtpAddress } } # Add the recipient forwarding configuration to the destinations array. $ForwardingDestinations += [PSCustomObject]@{ UserModified = $targetUser TargetSMTPAddress = $targetAddress ChangeType = "Recipient Forwarding" ModifiedBy = $change.UserId ModifiedTime = $change.CreationTime } } catch { # Log a warning if the recipient cannot be resolved. Out-LogFile "Unable to resolve forwarding recipient: $forwardingAddress" -isError # Add an unresolved entry for transparency in the output. $ForwardingDestinations += [PSCustomObject]@{ UserModified = $targetUser TargetSMTPAddress = "UNRESOLVED:$forwardingAddress" ChangeType = "Recipient Forwarding (Unresolved)" ModifiedBy = $change.UserId ModifiedTime = $change.CreationTime } } } } } Out-LogFile "Completed processing forwarding changes" -Information if ($ForwardingDestinations.Count -gt 0) { # Log the total number of forwarding destinations detected. Out-LogFile ("Found " + $ForwardingDestinations.Count + " forwarding destinations configured") -Information # Write the forwarding destinations data to files for review. $ForwardingDestinations | Out-MultipleFileType -FilePrefix "Forwarding_Recipients" -csv -json -Notice # Log details about each forwarding destination for detailed auditing. foreach ($dest in $ForwardingDestinations) { Out-LogFile "Forwarding configured: $($dest.UserModified) -> $($dest.TargetSMTPAddress) ($($dest.ChangeType)) by $($dest.ModifiedBy) at $($dest.ModifiedTime)" -Notice } } } else { # Log a warning if the parsing of audit data fails. Out-LogFile "Error: Failed to parse forwarding change audit data" -isError } } else { # Log a message if no forwarding changes are found in the logs. Out-LogFile "Get-HawkTenantAdminEmailForwardingChange completed successfully" -Information Out-LogFile "No forwarding changes found in filtered results" -action Out-LogFile "Retrieved $($AllMailboxChanges.Count) total operations, but none involved forwarding changes" -action } } catch { # Log an error if the analysis encounters an exception. Out-LogFile "Error analyzing email forwarding changes: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of email forwarding configuration changes from the UAL." -Information } Function Get-HawkTenantAdminInboxRuleCreation { <# .SYNOPSIS Retrieves audit log entries for inbox rules that were historically created within the tenant. .DESCRIPTION This function queries the Microsoft 365 Unified Audit Log for events classified as inbox rule creation (New-InboxRule). It focuses on historical record-keeping and identifying potentially suspicious rules that were created. The logged events do not indicate the specific method or interface used to create the rules. Key points: - Displays creation events for inbox rules, including who created them and when. - Flags created rules that appear suspicious (e.g., rules that forward externally, delete messages, or filter based on suspicious keywords). - Does not confirm whether the rules are currently active or still exist. For current, active rules, use Get-HawkTenantInboxRules. .OUTPUTS File: Simple_Admin_Inbox_Rules_Creation.csv/.json Path: \Tenant Description: Simplified view of created inbox rule events. File: Admin_Inbox_Rules_Creation.csv/.json Path: \Tenant Description: Detailed audit log data for created inbox rules. File: _Investigate_Admin_Inbox_Rules_Creation.csv/.json Path: \Tenant Description: A subset of historically created rules flagged as suspicious. .EXAMPLE Get-HawkTenantAdminInboxRuleCreation Retrieves events for all admin inbox rules created and available within the audit logs within the configured search window. Remarks: This basic example pulls all inbox rule creations from the audit log and analyzes them for suspicious patterns. Output files will be created in the configured Hawk output directory under the Tenant subfolder. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of admin inbox rule creation events from the UAL." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Search for new inbox rules $searchCommand = "Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations 'New-InboxRule'" [array]$NewInboxRules = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand Out-LogFile "Searching Unified Audit Log for inbox rule creation events." -Action if ($NewInboxRules.Count -gt 0) { Out-LogFile ("Found " + $NewInboxRules.Count + " admin inbox rule changes in Unified Audit Log.") -Information # Process and output the results $ParsedRules = $NewInboxRules | Get-SimpleUnifiedAuditLog if ($ParsedRules) { Out-LogFile "Writing parsed admin inbox rule creation data." -Action $ParsedRules | Out-MultipleFileType -FilePrefix "Simple_Admin_Inbox_Rules_Creation" -csv -json $NewInboxRules | Out-MultipleFileType -FilePrefix "Admin_Inbox_Rules_Creation" -csv -json # Check for suspicious rules using the helper function $SuspiciousRules = $ParsedRules | Where-Object { $reasons = @() Test-SuspiciousInboxRule -Rule $_ -Reasons ([ref]$reasons) } if ($SuspiciousRules) { Out-LogFile "Found $($SuspiciousRules.Count) inbox rule creation events." -Notice Out-LogFile "Please verify this activity is legitimate."-Notice $SuspiciousRules | Out-MultipleFileType -FilePrefix "_Investigate_Admin_Inbox_Rules_Creation" -csv -json -Notice } } else { Out-LogFile "Error: Failed to parse inbox rule audit data." -isError } } else { Out-LogFile "Completed collection of admin inbox rule creation events from the UAL." -Information Out-LogFile "No admin inbox rule creation events found in audit logs" -Action } } catch { Out-LogFile "Error analyzing admin inbox rule creation: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of admin inbox rule creation events from the UAL." -Information } Function Get-HawkTenantAdminInboxRuleModification { <# .SYNOPSIS Retrieves audit log entries for inbox rules that were historically modified within the tenant. .DESCRIPTION This function queries the Microsoft 365 Unified Audit Logs for events classified as inbox rule modification (Set-InboxRule). It focuses on past changes to existing rules, helping identify suspicious modifications (e.g., forwarding to external addresses, enabling deletion, or targeting sensitive keywords). The logged events do not indicate how or where the modification took place, only that an inbox rule was changed at a given time by a specific account. Key points: - Shows modification events for inbox rules, including who modified them and when. - Flags modifications that may be suspicious based on predefined criteria. - Does not indicate whether the rules are currently active or still exist. For current, active rules, use Get-HawkTenantInboxRules. .OUTPUTS File: Simple_Admin_Inbox_Rules_Modification.csv/.json Path: \Tenant Description: Simplified view of inbox rule modification events. File: Admin_Inbox_Rules_Modification.csv/.json Path: \Tenant Description: Detailed audit log data for modified inbox rules. File: _Investigate_Admin_Inbox_Rules_Modification.csv/.json Path: \Tenant Description: A subset of historically modified rules flagged as suspicious. .EXAMPLE Get-HawkTenantAdminInboxRuleModification Retrieves events for all admin inbox rules modified and available within the audit logs within the configured search window. Remarks: This basic example pulls all inbox rule modification logs from the audit log and analyzes them for suspicious patterns. Output files will be created in the configured Hawk output directory under the Tenant subfolder. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of admin inbox rule modification events from the UAL." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Search for modified inbox rules Out-LogFile "Searching audit logs for inbox rule modification events" -Action $searchCommand = "Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations 'Set-InboxRule'" [array]$ModifiedInboxRules = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand if ($ModifiedInboxRules.Count -gt 0) { Out-LogFile ("Found " + $ModifiedInboxRules.Count + " admin inbox rule modifications in audit logs") -Information # Process and output the results $ParsedRules = $ModifiedInboxRules | Get-SimpleUnifiedAuditLog if ($ParsedRules) { Out-LogFile "Writing parsed admin inbox rule modification data" -Action $ParsedRules | Out-MultipleFileType -FilePrefix "Simple_Admin_Inbox_Rules_Modification" -csv -json $ModifiedInboxRules | Out-MultipleFileType -FilePrefix "Admin_Inbox_Rules_Modification" -csv -json # Check for suspicious modifications using the helper function $SuspiciousModifications = $ParsedRules | Where-Object { $reasons = @() Test-SuspiciousInboxRule -Rule $_ -Reasons ([ref]$reasons) } if ($SuspiciousModifications) { Out-LogFile "Found $($SuspiciousModifications.Count) inbox rule modification events" -Notice Out-LogFile "Please verify this activity is legitimate." -Notice $SuspiciousModifications | Out-MultipleFileType -FilePrefix "_Investigate_Admin_Inbox_Rules_Modification" -csv -json -Notice } } else { Out-LogFile "Error: Failed to parse inbox rule audit data" -isError } } else { Out-LogFile "Get-HawkTenantAdminInboxRuleModification completed successfully" -Information Out-LogFile "No inbox rule modifications found in audit logs" -action } } catch { Out-LogFile "Error analyzing admin inbox rule modifications: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of admin inbox rule modification events from the UAL." -Information } Function Get-HawkTenantAdminInboxRuleRemoval { <# .SYNOPSIS Retrieves audit log entries for inbox rules that were removed within the tenant. .DESCRIPTION This function queries the Microsoft 365 Unified Audit Log for events classified as inbox rule removal (Remove-InboxRule). It focuses on historical record-keeping and identifying when inbox rules were removed and by whom. The logged events do not indicate the specific method or interface used to remove the rules. Key points: - Displays removal events for inbox rules, including who removed them and when. - Flags removals that might be suspicious (e.g., rules that were forwarding externally). - Provides historical context for rule removals during investigations. For current, active rules, use Get-HawkTenantInboxRules. .OUTPUTS File: Simple_Admin_Inbox_Rules_Removal.csv/.json Path: \Tenant Description: Simplified view of removed inbox rule events. File: Admin_Inbox_Rules_Removal.csv/.json Path: \Tenant Description: Detailed audit log data for removed inbox rules. File: _Investigate_Admin_Inbox_Rules_Removal.csv/.json Path: \Tenant Description: A subset of historically removed rules flagged as suspicious. .EXAMPLE Get-HawkTenantAdminInboxRuleRemoval Retrieves events for all removed inbox rules from the audit logs within the specified search window, highlighting any that appear suspicious. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of admin inbox rule removal events from the UAL." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Search for removed inbox rules Out-LogFile "Searching audit logs for inbox rule removals" -action $searchCommand = "Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations 'Remove-InboxRule'" [array]$RemovedInboxRules = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand if ($RemovedInboxRules.Count -gt 0) { Out-LogFile ("Found " + $RemovedInboxRules.Count + " admin inbox rule removals in audit logs") -Information # Process and output the results $ParsedRules = $RemovedInboxRules | Get-SimpleUnifiedAuditLog if ($ParsedRules) { # Output simple format for easy analysis $ParsedRules | Out-MultipleFileType -FilePrefix "Simple_Admin_Inbox_Rules_Removal" -csv -json # Output full audit logs for complete record $RemovedInboxRules | Out-MultipleFileType -FilePrefix "Admin_Inbox_Rules_Removal" -csv -json # Check for suspicious removals $SuspiciousRemovals = $ParsedRules | Where-Object { $reasons = @() Test-SuspiciousInboxRule -Rule $_ -Reasons ([ref]$reasons) } if ($SuspiciousRemovals) { Out-LogFile "Found $($SuspiciousRemovals.Count) inbox rule removal events" -Notice Out-LogFile "Please verify this activity is legitimate." -Notice $SuspiciousRemovals | Out-MultipleFileType -FilePrefix "_Investigate_Admin_Inbox_Rules_Removal" -csv -json -Notice } } else { Out-LogFile "Error: Failed to parse inbox rule removal audit data" -isError } } else { Out-LogFile "Get-HawkTenantAdminInboxRuleRemoval completed successfully" -Information Out-LogFile "No inbox rule removals found in audit logs" -action } } catch { Out-LogFile "Error analyzing admin inbox rule removals: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of admin inbox rule removal events from the UAL." -Information } Function Get-HawkTenantAdminMailboxPermissionChange { <# .SYNOPSIS Retrieves audit log entries for mailbox permission changes within the tenant. .DESCRIPTION Searches the Unified Audit Log for mailbox permission changes and flags any grants of FullAccess, SendAs, or Send on Behalf permissions for investigations. Excludes normal system operations on Discovery Search Mailboxes. .OUTPUTS File: Simple_Mailbox_Permission_Change.csv/.json Path: \Tenant Description: Simplified view of mailbox permission changes. File: Mailbox_Permission_Change.csv/.json Path: \Tenant Description: Detailed audit log data for permission changes. File: _Investigate_Mailbox_Permission_Change.csv/.json Path: \Tenant Description: Permission changes that granted sensitive rights. .EXAMPLE Get-HawkTenantAdminMailboxPermissionChange Retrieves mailbox permission change events from the audit logs. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of mailbox permission changes from the UAL." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Search for mailbox permission changes Out-LogFile "Searching audit logs for mailbox permission changes" -action $searchCommand = "Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations 'Add-MailboxPermission','Add-RecipientPermission','Add-ADPermission'" [array]$PermissionChanges = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand if ($PermissionChanges.Count -gt 0) { Out-LogFile ("Found " + $PermissionChanges.Count + " mailbox permission changes in audit logs") -Information # Process and output the results $ParsedChanges = $PermissionChanges | Get-SimpleUnifiedAuditLog if ($ParsedChanges) { # Output simple format for easy analysis $ParsedChanges | Out-MultipleFileType -FilePrefix "Simple_Mailbox_Permission_Change" -csv -json # Output full audit logs for complete record $PermissionChanges | Out-MultipleFileType -FilePrefix "Mailbox_Permission_Change" -csv -json # Check for sensitive permissions, excluding Discovery Search Mailbox system operations $SensitiveGrants = $ParsedChanges | Where-Object { # First check if this is potentially sensitive permission ($_.Param_AccessRights -match 'FullAccess|SendAs' -or $_.Operation -eq 'Add-ADPermission' -or $_.Operation -match 'Add-RecipientPermission') -and # Then exclude DiscoverySearchMailbox system operations -not ( $_.UserId -eq "NT AUTHORITY\SYSTEM (Microsoft.Exchange.ServiceHost)" -and $_.ObjectId -like "*DiscoverySearchMailbox*" -and $_.Param_User -like "*Discovery Management*" ) } if ($SensitiveGrants) { Out-LogFile "Found $($SensitiveGrants.Count) mailbox permission changes" -Notice Out-LogFile "Please verify this activity is legitimate."-Notice $SensitiveGrants | Out-MultipleFileType -FilePrefix "_Investigate_Mailbox_Permission_Change" -csv -json -Notice } } else { Out-LogFile "Error: Failed to parse mailbox permission audit data" -isError } } else { Out-LogFile "Get-HawkTenantAdminMailBoxPermissionChange completed successfully" -Information Out-LogFile "No mailbox permission changes found in audit logs" -action } } catch { Out-LogFile "Error analyzing mailbox permission changes: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of mailbox permission changes from the UAL." -Information } Function Get-HawkTenantAppAndSPNCredentialDetail { <# .SYNOPSIS Tenant Azure Active Directory Applications and Service Principal Credential details export using Microsoft Graph. .DESCRIPTION Tenant Azure Active Directory Applications and Service Principal Credential details export. Credential details can be used to review when credentials were created for an Application or Service Principal. If a malicious user created a certificate or password used to access corporate data, then knowing the key creation time will be instrumental to determining the time frame of when an attacker had access to data. .EXAMPLE Get-HawkTenantAppAndSPNCredentialDetail Gets all Tenant Application and Service Principal Details .OUTPUTS SPNCertsAndSecrets.csv ApplicationCertsAndSecrets .LINK https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list https://learn.microsoft.com/en-us/graph/api/application-list .NOTES Updated to use Microsoft Graph API instead of AzureAD module #> [CmdletBinding()] param() BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } # Create Tenant folder path if it doesn't exist $tenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $tenantPath)) { New-Item -Path $tenantPath -ItemType Directory -Force | Out-Null } Out-LogFile "Initiating collection of application and service principal credentials." -Action Test-GraphConnection Send-AIEvent -Event "CmdRun" # Initialize arrays to collect all results $spnResults = @() $appResults = @() Out-LogFile "Collecting Entra ID Service Principals" -Action try { $spns = Get-MgServicePrincipal -All | Sort-Object -Property DisplayName Out-LogFile "Collecting Entra ID Registered Applications" -Action $apps = Get-MgApplication -All | Sort-Object -Property DisplayName } catch { Out-LogFile "Error retrieving Service Principals or Applications: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } PROCESS { try { Out-LogFile "Exporting Service Principal Certificate and Password details" -Action foreach ($spn in $spns) { # Process key credentials foreach ($key in $spn.KeyCredentials) { $newapp = [PSCustomObject]@{ AppName = $spn.DisplayName AppObjectID = $spn.Id KeyID = $key.KeyId StartDate = $key.StartDateTime EndDate = $key.EndDateTime KeyType = $key.Type CredType = "X509Certificate" } # Add to array for JSON output $spnResults += $newapp # Output individual record to CSV $newapp | Out-MultipleFileType -FilePrefix "SPNCertsAndSecrets" -csv -append } # Process password credentials foreach ($pass in $spn.PasswordCredentials) { $newapp = [PSCustomObject]@{ AppName = $spn.DisplayName AppObjectID = $spn.Id KeyID = $pass.KeyId StartDate = $pass.StartDateTime EndDate = $pass.EndDateTime KeyType = $null CredType = "PasswordSecret" } # Add to array for JSON output $spnResults += $newapp # Output individual record to CSV $newapp | Out-MultipleFileType -FilePrefix "SPNCertsAndSecrets" -csv -append } } # Output complete SPN results array as single JSON if ($spnResults.Count -gt 0) { $spnResults | ConvertTo-Json | Out-File -FilePath (Join-Path -Path $tenantPath -ChildPath "SPNCertsAndSecrets.json") } Out-LogFile "Exporting Registered Applications Certificate and Password details" -Action foreach ($app in $apps) { # Process key credentials foreach ($key in $app.KeyCredentials) { $newapp = [PSCustomObject]@{ AppName = $app.DisplayName AppObjectID = $app.Id KeyID = $key.KeyId StartDate = $key.StartDateTime EndDate = $key.EndDateTime KeyType = $key.Type CredType = "X509Certificate" } # Add to array for JSON output $appResults += $newapp # Output individual record to CSV $newapp | Out-MultipleFileType -FilePrefix "ApplicationCertsAndSecrets" -csv -append } # Process password credentials foreach ($pass in $app.PasswordCredentials) { $newapp = [PSCustomObject]@{ AppName = $app.DisplayName AppObjectID = $app.Id KeyID = $pass.KeyId StartDate = $pass.StartDateTime EndDate = $pass.EndDateTime KeyType = $pass.Type CredType = "PasswordSecret" } # Add to array for JSON output $appResults += $newapp # Output individual record to CSV $newapp | Out-MultipleFileType -FilePrefix "ApplicationCertsAndSecrets" -csv -append } } # Output complete application results array as single JSON if ($appResults.Count -gt 0) { $appResults | ConvertTo-Json | Out-File -FilePath (Join-Path -Path $tenantPath -ChildPath "ApplicationCertsAndSecrets.json") } } catch { Out-LogFile "Error processing credentials: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } END { Out-LogFile "Completed collection of application and service principal credentials from Microsoft Graph." -Information } } Function Get-HawkTenantConfiguration { <# .SYNOPSIS Gather basic tenant configuration and saves the output to a text file .DESCRIPTION Gather basic tenant configuration and saves the output to a text file Gathers information about tenant wide settings * Admin Audit Log Configuration * Organization Configuration * Remote domains * Transport Rules * Transport Configuration .EXAMPLE PS C:\> Get-HawkTenantConfiguration Explanation of what the example does .INPUTS Inputs (if any) .OUTPUTS File: AdminAuditLogConfig.txt Path: \ Description: Output of Get-AdminAuditlogConfig File: OrgConfig.txt Path: \ Description: Output of Get-OrganizationConfig File: RemoteDomain.txt Path: \ Description: Output of Get-RemoteDomain File: TransportRules.txt Path: \ Description: Output of Get-TransportRule File: TransportConfig.txt Path: \ Description: Output of Get-TransportConfig .NOTES TODO: Put in some analysis ... flag some key things that we know we should #> # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of tenant configuration settings from Exchange Online." -Action Test-EXOConnection Send-AIEvent -Event "CmdRun" #Check Audit Log Config Setting and make sure it is enabled Out-LogFile "Gathering Tenant Configuration Information" -action Out-LogFile "Gathering Admin Audit Log" -action Get-AdminAuditLogConfig | Out-MultipleFileType -FilePrefix "AdminAuditLogConfig" -txt Out-LogFile "Gathering Organization Configuration" -action Get-OrganizationConfig| Out-MultipleFileType -FilePrefix "OrgConfig" -txt Out-LogFile "Gathering Remote Domains" -action Get-RemoteDomain | Out-MultipleFileType -FilePrefix "RemoteDomain" -csv -json Out-LogFile "Gathering Transport Rules" -action Get-TransportRule | Out-MultipleFileType -FilePrefix "TransportRules" -csv -json Out-LogFile "Gathering Transport Configuration" -action Get-TransportConfig | Out-MultipleFileType -FilePrefix "TransportConfig" -csv -json Out-LogFile "Completed collection of tenant configuration settings from Exchange Online." -Information } Function Get-HawkTenantConsentGrant { <# .SYNOPSIS Gathers application grants using Microsoft Graph .DESCRIPTION Uses Microsoft Graph to gather information about application and delegate grants. Attempts to detect high risk grants for review. This function is used to identify potentially risky application permissions and consent grants in your tenant. .EXAMPLE Get-HawkTenantConsentGrant Gathers and analyzes all OAuth grants in the tenant. .OUTPUTS File: Consent_Grants.csv Path: \Tenant Description: Output of all consent grants with details about permissions and access .NOTES This function requires the following Microsoft Graph permissions: - Application.Read.All - Directory.Read.All #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of OAuth / Application Grants from Microsoft Graph." -Action Test-GraphConnection Send-AIEvent -Event "CmdRun" # Gather the grants using the internal Graph-based implementation [array]$Grants = Get-AzureADPSPermission -ShowProgress # Create new Property for Consent_Grants output table $Grants | Add-Member -NotePropertyName ConsentGrantRiskCategory -NotePropertyValue "" [bool]$flag = $false # Define list of Extremely Dangerous grants [array]$ExtremelyDangerousGrants = "^AppRoleAssignment\.ReadWrite\.All$", "^RoleManagement\.ReadWrite\.Directory$" # Define list of High Risk grants [array]$HighRiskGrants = "^BitlockerKey\.Read\.All$", "^Chat\.", "^Directory\.ReadWrite\.All$", "^eDiscovery\.", "^Files\.", "^MailboxSettings\.ReadWrite$", "^Mail\.ReadWrite$", "^Mail\.Send$", "^Sites\.", "^User\." # Search the Grants for the listed bad grants that we can detect #Flag broad-scope grants [int]$BroadGrantCount = 0 $Grants | ForEach-Object -Process { if($_.ConsentType -contains 'AllPrincipals' -or $_.Permission -match 'all') { $_.ConsentGrantRiskCategory = "Broad-Scope Grant" $BroadGrantCount += 1 } } if($BroadGrantCount -gt 0) { Out-LogFile "Found $BroadGrantCount broad-scoped grants ('AllPrincipals' or '*.All')" -notice $flag = $true } #Flag Extremely Dangerous grants; if a grant is both broad-scope and E.D., flag as E.D. [int]$EDGrantCount = 0 foreach($grant in $ExtremelyDangerousGrants) { $Grants | ForEach-Object -Process { if($_.Permission -match $grant){ $_.ConsentGrantRiskCategory = "Extremely Dangerous" $EDGrantCount += 1 } } } if ($EDGrantCount -gt 0) { Out-LogFile "Found $EDGrantCount Extremely Dangerous Grant(s)" -notice $flag = $true } #Flag High Risk grants; if a grant is both broad-scope and H.R., flag as H.R. [int]$HRGrantCount = 0 foreach($grant in $HighRiskGrants) { $Grants | ForEach-Object -Process { if($_.Permission -match $grant){ $_.ConsentGrantRiskCategory = "High Risk" $HRGrantCount += 1 } } } if ($HRGrantCount -gt 0) { Out-LogFile "Found $HRGrantCount High Risk Grant(s)" -notice $flag = $true } if ($flag) { Out-LogFile "Please verify these grants are legitimate / required." -Notice Out-LogFile 'For more information on understanding these results results, visit' -Notice Out-LogFile 'https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants' -Notice # Create investigation file for concerning grants $grantsForInvestigation = $Grants | Where-Object { $_.ConsentGrantRiskCategory -ne "" } $grantsForInvestigation | Out-MultipleFileType -FilePrefix "_Investigate_Consent_Grants" -csv -json -Notice } else { Out-LogFile "To review this data follow:" -Information Out-LogFile "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants" -Information } # Output all grants $Grants | Out-MultipleFileType -FilePrefix "Consent_Grants" -csv -json Out-LogFile "Completed collection of OAuth / Application Grants from Microsoft Graph." -Information } # Search for any changes made to RBAC in the search window and report them Function Get-HawkTenantDomainActivity { <# .SYNOPSIS Looks for any changes made to M365 Domains. Permissions required to make the changes that thsi function is looking for is "Domain Name Administrator" or "Global Administrator .DESCRIPTION Searches the EXO Audit logs for the following commands being run. Set-AccpetedDomain Add-FederatedDomain New-AcceptedDomain Update Domain Add Verified Domain Add Unverified Domain .OUTPUTS File: Domain_Activity_Changes.csv Path: \ Description: All Domain activity actions File: Domain_Activity_Changes.xml Path: \XML Description: All Domain configuration actions .EXAMPLE Get-HawkTenantDomainActivity Searches for all Domain configuration actions #> BEGIN{ # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of domain configuration changes from the UAL." -Action } PROCESS{ # Search UAL audit logs for any Domain configuration changes $DomainConfigurationEvents = Get-AllUnifiedAuditLogEntry -UnifiedSearch ("Search-UnifiedAuditLog -RecordType 'AzureActiveDirectory' -Operations 'Set-AcceptedDomain','Add-FederatedDomain','Update Domain','Add verified domain', 'Add unverified domain', 'remove unverified domain'") # If null we found no changes to nothing to do here if ($null -eq $DomainConfigurationEvents){ Out-LogFile "Get-HawkTenantDomainActivity completed successfully" -Information Out-LogFile "No Domain configuration changes found." -Action } # If not null then we must have found some events so flag them else{ Out-LogFile "Domain configuration changes found." -Notice Out-LogFile "Please review these Domain_Changes_Audit to ensure any changes are legitimate." -Notice # Go thru each even and prepare it to output to CSV Foreach ($event in $DomainConfigurationEvents){ $log1 = $event.auditdata | ConvertFrom-Json <# $domainarray = $log1.ModifiedProperties $useragentarray = $log1.ExtendedProperties if ($domainarray){ $result1 = ($log1.ModifiedProperties.NewValue).Split('"') $Domain = $result1[1] } else { $Domain = "Domain Not Provided by Audit Log" } if ($useragentarray){ $result2 = ($log1.ExtendedProperties.Value).Split('"') $UserAgentString = $result2[3] } else { $UserAgentString = "User Agent String Found" } $newlog = $log1 | Select-Object -Property CreationTime, Id, Workload, Operation, ResultStatus, UserID, @{Name='Domain';Expression={$Domain}}, @{Name='User Agent String';Expression={$UserAgentString}}, @{Name='Target';Expression={($_.Target.ID)}} #> $event | Out-MultipleFileType -fileprefix "Domain_Changes_Audit" -csv -append $log1 | Out-MultipleFileType -fileprefix "Domain_Changes_Audit" -json -append } } } END{ Out-LogFile "Completed collection of domain configuration changes from the UAL." -Information } }#End Function Get-HawkTenantDomainActivity Function Get-HawkTenantEDiscoveryConfiguration { <# .SYNOPSIS Gets complete eDiscovery configuration data across built-in and custom role assignments. .DESCRIPTION Retrieves comprehensive eDiscovery permissions data from two distinct sources in Exchange Online: 1. Built-in Exchange Online Role Groups: - Standard eDiscovery roles like "Discovery Management" - Pre-configured with specific eDiscovery capabilities - Managed through Exchange admin center - Typically used for organization-wide eDiscovery access - Includes mailbox search and hold capabilities - Part of Microsoft's default security model 2. Custom Management Role Entries: - User-created roles with eDiscovery permissions - Can be tailored for specific business needs - May include subset of eDiscovery capabilities - Often created for specialized teams or scenarios - Requires careful monitoring for security - May grant permissions through role assignments - Can include cmdlets like: * New-MailboxSearch * Search-Mailbox The function captures all properties and relationships to provide a complete view of who has eDiscovery access and how those permissions were granted. This helps security teams audit and manage eDiscovery permissions effectively. .OUTPUTS File: EDiscoveryRoles.csv/.json Path: \Tenant Description: Complete data about standard Exchange Online eDiscovery role groups Contains: Role names, members, assigned permissions, creation dates, and all associated properties for built-in eDiscovery roles File: CustomEDiscoveryRoles.csv/.json Path: \Tenant Description: Complete data about custom roles with eDiscovery permissions Contains: Custom role definitions, assignments, scope, creation dates, and all configurable properties for user-created roles with eDiscovery access .EXAMPLE Get-HawkTenantEDiscoveryConfiguration Returns complete, unfiltered eDiscovery permission data showing both built-in role groups and custom role assignments that grant eDiscovery access. .NOTES Built-in roles provide consistent, pre-configured access while custom roles offer flexibility but require more oversight. Regular review of both types is recommended for security compliance. #> [CmdletBinding()] param() #TO DO: UPDATE THIS FUNCTION TO FIND E-Discovery roles created via the graph API BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of eDiscovery configuration data from Exchange Online." -Action # Create tenant folder if needed $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } # Null out role arrays [array]$Roles = $null [array]$RoleAssignements = $null } PROCESS { try { #region Exchange Online Role Groups - Full Data Out-LogFile "Gathering all Exchange Online role entries with eDiscovery cmdlets" -Action # Find any roles that have eDiscovery cmdlets $EDiscoveryCmdlets = "New-MailboxSearch", "Search-Mailbox" foreach ($cmdlet in $EDiscoveryCmdlets) { [array]$Roles = $Roles + (Get-ManagementRoleEntry ("*\" + $cmdlet)) } # Select just the unique entries based on role name if ($Roles) { $UniqueRoles = $Roles | Sort-Object -Property Role -Unique Out-LogFile ("Found " + $UniqueRoles.Count + " Roles with E-Discovery Rights") -Information # Save complete role data $UniqueRoles | ConvertTo-Json -Depth 100 | Out-File (Join-Path -Path $TenantPath -ChildPath "EDiscoveryRoles.json") $UniqueRoles | Export-Csv -Path (Join-Path -Path $TenantPath -ChildPath "EDiscoveryRoles.csv") -NoTypeInformation # Get everyone who is assigned one of these roles foreach ($Role in $UniqueRoles) { [array]$RoleAssignements = $RoleAssignements + (Get-ManagementRoleAssignment -Role $Role.Role -Delegating $false) } if ($RoleAssignements) { Out-LogFile ("Found " + $RoleAssignements.Count + " Role Assignments for these Roles") -Information # Save complete assignment data $RoleAssignements | ConvertTo-Json -Depth 100 | Out-File (Join-Path -Path $TenantPath -ChildPath "CustomEDiscoveryRoles.json") $RoleAssignements | Export-Csv -Path (Join-Path -Path $TenantPath -ChildPath "CustomEDiscoveryRoles.csv") -NoTypeInformation } else { Out-LogFile "Get-HawkTenantEDiscoveryConfiguration completed successfully" -Information Out-LogFile "No role assignments found" -action } } else { Out-LogFile "Get-HawkTenantEDiscoveryConfiguration completed successfully" -Information Out-LogFile "No roles with eDiscovery cmdlets found" -action } #endregion } catch { Out-LogFile "Error gathering eDiscovery configuration: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } END { Out-LogFile "Completed collection of eDiscovery configuration data from Exchange Online." -Information } } Function Get-HawkTenantEDiscoveryLog { <# .SYNOPSIS Gets Unified Audit Logs (UAL) data for eDiscovery .DESCRIPTION Searches the Unified Audit Log (UAL) for eDiscovery events and activities. This includes searches, exports, and management activities related to eDiscovery cases. The function checks for any eDiscovery activities within the timeframe specified in the Hawk global configuration object. The results can help identify: * When eDiscovery searches were performed * Who performed eDiscovery activities * Which cases were accessed or modified * What operations were performed .EXAMPLE Get-HawkTenantEDiscoveryLog This will search for all eDiscovery-related activities in the Unified Audit Log for the configured time period and export the results to CSV and JSON formats. .EXAMPLE $logs = Get-HawkTenantEDiscoveryLog $logs | Where-Object {$_.Operation -eq "SearchCreated"} This example shows how to retrieve eDiscovery logs and filter for specific operations like new search creation. .OUTPUTS File: Simple_eDiscoveryLogs.csv/.json Path: \Tenant Description: Simplified view of eDiscovery activities. File: eDiscoveryLogs.csv/.json Path: \Tenant Description: Contains all eDiscovery activities found in the UAL with fields for: - CreationTime: When the activity occurred - Id: Unique identifier for the activity - Operation: Type of eDiscovery action performed - Workload: The workload where the activity occurred - UserID: User who performed the action - Case: eDiscovery case name - CaseId: Unique identifier for the eDiscovery case - Cmdlet: Command that was executed (if applicable) #> # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of eDiscovery configuration data from Exchange Online." -Action # Search UAL audit logs for any eDiscovery activities $eDiscoveryLogs = Get-AllUnifiedAuditLogEntry -UnifiedSearch ("Search-UnifiedAuditLog -RecordType 'Discovery'") if ($null -eq $eDiscoveryLogs) { Out-LogFile "Get-HawkTenantEDiscoveryLog completed successfully" -Information Out-LogFile "No eDiscovery Logs found" -Action } else { Out-LogFile "eDiscovery Logs have been found." -Notice Out-LogFile "Please review these eDiscoveryLogs.csv to validate the activity is legitimate." -Notice # Process and output both simple and detailed formats $ParsedLogs = $eDiscoveryLogs | Get-SimpleUnifiedAuditLog if ($ParsedLogs) { Out-LogFile "Writing parsed eDiscovery log data" -Action $ParsedLogs | Out-MultipleFileType -FilePrefix "Simple_eDiscoveryLogs" -csv -json $eDiscoveryLogs | Out-MultipleFileType -FilePrefix "eDiscoveryLogs" -csv -json } else { Out-LogFile "Error: Failed to parse eDiscovery log data" -isError } } Out-LogFile "Completed collection of eDiscovery logs from Exchange Online." -Information } Function Get-HawkTenantEntraIDAdmin { <# .SYNOPSIS Tenant Microsoft Entra ID Administrator export using Microsoft Graph. .DESCRIPTION Tenant Microsoft Entra ID Administrator export. Reviewing administrator access is key to knowing who can make changes to the tenant and conduct other administrative actions to users and applications. .EXAMPLE Get-HawkTenantEntraIDAdmin Gets all Entra ID Admins .OUTPUTS EntraIDAdministrators.csv EntraIDAdministrators.json .LINK https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/get-mgdirectoryrole .NOTES Requires Microsoft.Graph.Identity.DirectoryManagement module #> [CmdletBinding()] param() BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of Microsoft Entra ID Administrators from Microsoft Graph." -Action # Verify Graph API connection Test-GraphConnection Send-AIEvent -Event "CmdRun" } PROCESS { try { # Retrieve all directory roles from Microsoft Graph $directoryRoles = Get-MgDirectoryRole -ErrorAction Stop Out-LogFile "Retrieved $(($directoryRoles | Measure-Object).Count) directory roles" -Information # Process each role and its members $roles = foreach ($role in $directoryRoles) { # Get all members assigned to current role $members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -ErrorAction Stop # Handle roles with no members if (-not $members) { [PSCustomObject]@{ AdminGroupName = $role.DisplayName Members = "No Members" MemberType = "None" # Added member type for better analysis ObjectId = $null } } else { # Process each member of the role foreach ($member in $members) { # Check if member is a user if ($member.AdditionalProperties.'@odata.type' -eq "#microsoft.graph.user") { [PSCustomObject]@{ AdminGroupName = $role.DisplayName Members = $member.AdditionalProperties.userPrincipalName MemberType = "User" ObjectId = $member.Id } } else { # Handle groups and service principals [PSCustomObject]@{ AdminGroupName = $role.DisplayName Members = $member.AdditionalProperties.displayName MemberType = ($member.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.', '') ObjectId = $member.Id } } } } } # Export results if any roles were found if ($roles) { $roles | Out-MultipleFileType -FilePrefix "EntraIDAdministrators" -csv -json Out-LogFile "Successfully exported Microsoft Entra ID Administrators data" -Information } else { Out-LogFile "Get-HawkTenantEntraID completed" -Information Out-LogFile "No administrator roles found or accessible" -Action } } catch { # Handle and log any errors during execution Out-LogFile "Error retrieving Microsoft Entra ID Administrators: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } END { Out-LogFile "Completed collection of Microsoft Entra ID Administrators from Microsoft Graph." -Information } } Function Get-HawkTenantEntraIDAppAuditLog{ <# .SYNOPSIS Retrieves audit logs for application permission and consent events in Microsoft Entra ID. .DESCRIPTION This function searches the Microsoft 365 Unified Audit Log for historical events related to application permissions and consent grants in Microsoft Entra ID (formerly Azure AD). It focuses on tracking when and by whom application permissions were granted or modified. Key events tracked: - OAuth2 permission grant additions - Application consent grants - Changes to application permissions The function provides historical context to complement Get-HawkTenantConsentGrant, which shows current permission states. While Get-HawkTenantConsentGrant shows what permissions exist now, this function helps you understand how and when those permissions were established. The audit data includes: - Timestamp of permission changes - UserID/UPN of who made the changes - Target application details - Client IP address of where changes originated - Operation details and result status .OUTPUTS File: Entra_ID_Application_Audit.csv/.json Path: \Tenant Description: Contains all application permission and consent events found in the audit logs with fields for: - Id: Unique identifier for the audit event - Operation: Type of operation performed (e.g., Add OAuth2PermissionGrant) - ResultStatus: Success/failure status of the operation - Workload: The workload where the operation occurred - ClientIP: IP address where the operation originated - UserID: User who performed the operation - ActorUPN: UserPrincipalName of the user who performed the action - TargetName: Name of the application affected - EnvTime: Timestamp of the event - CorrelationId: Identifier to correlate related events .EXAMPLE Get-HawkTenantEntraIDAppAuditLog Searches the audit logs for all application permission and consent events within the configured time window. Results are exported to Entra_ID_Application_Audit.csv and .json files. .NOTES Author: Jonathan Butler Version: 4.0 .LINK https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants .LINK https://learn.microsoft.com/en-us/microsoft-365/compliance/audit-log-activities #> Begin { #Initializing Hawk Object if not present # Check if Hawk object exists and is fully initialized # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Gathering Tenant information" -Action Test-EXOConnection Test-GraphConnection Send-AIEvent -Event "CmdRun" }#End BEGIN PROCESS{ # Make sure our variables are null $AzureApplicationActivityEvents = $null Out-LogFile "Initiating collection of Entra ID application audit events from the UAL." -Action # Search the unified audit log for events related to application activity # https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants $AzureApplicationActivityEvents = Get-AllUnifiedAuditLogEntry -UnifiedSearch ("Search-UnifiedAuditLog -RecordType 'AzureActiveDirectory' -Operations 'Add OAuth2PermissionGrant.','Consent to application.' ") # If null we found no changes to nothing to do here if ($null -eq $AzureApplicationActivityEvents){ Out-LogFile "Get-HawkTenantEntraIDAppAuditLog completed successfully" -Information Out-LogFile "No Application related events found in the search time frame." -Action } # If not null then we must have found some events so flag them else { Out-LogFile "Application Rights Activity found." -Notice Out-LogFile "Please review these Entra_ID_Application_Audit.csv to ensure any changes are legitimate." -Notice # Go thru each even and prepare it to output to CSV Foreach ($event in $AzureApplicationActivityEvents){ $event.auditdata | ConvertFrom-Json | Select-Object -Property Id, Operation, ResultStatus, Workload, ClientIP, UserID, @{Name='ActorUPN';Expression={($_.ExtendedProperties | Where-Object {$_.Name -eq 'actorUPN'}).value}}, @{Name='targetName';Expression={($_.ExtendedProperties | Where-Object {$_.Name -eq 'targetName'}).value}}, @{Name='env_time';Expression={($_.ExtendedProperties | Where-Object {$_.Name -eq 'env_time'}).value}}, @{Name='correlationId';Expression={($_.ExtendedProperties | Where-Object {$_.Name -eq 'correlationId'}).value}}` | Out-MultipleFileType -fileprefix "Entra_ID_Application_Audit" -csv -json -append } } }#End PROCESS END{ Out-LogFile "Completed collection of Entra ID application audit events from the UAL." -Information }#End END } Function Get-HawkTenantEntraIDAuditLog { <# .SYNOPSIS Retrieves Microsoft Entra ID audit logs using Microsoft Graph API. .DESCRIPTION This function queries the Microsoft Graph API to retrieve Entra ID audit logs. Due to API limitations, it can only retrieve logs for the past 30 days from the current date, regardless of the date range configured in Hawk. The function will warn if the configured Hawk date range extends beyond the available 30-day window, but will still retrieve all available logs within the allowed period. All retrieved audit log entries are exported in both CSV and JSON formats without any filtering or modification. .OUTPUTS File: EntraIDAuditLogs.csv Path: \Tenant Description: Complete Entra ID audit log entries from the last 30 days in CSV format File: EntraIDAuditLogs.json Path: \Tenant Description: Complete Entra ID audit log entries from the last 30 days in JSON format .EXAMPLE Get-HawkTenantEntraIDAuditLog Retrieves all available Entra ID audit logs from the past 30 days, regardless of the date range configured in Hawk. .NOTES Author: Jonathan Butler Version: 1.0 Requires the following Microsoft Graph permissions: - AuditLog.Read.All - Directory.Read.All IMPORTANT: The Microsoft Graph API for directory audit logs has a strict 30-day lookback limit from the current date. Any configured date ranges in Hawk that extend beyond this window will be noted, but the function will still retrieve all available logs within the allowed 30-day period. .LINK https://learn.microsoft.com/en-us/graph/api/directoryaudit-list #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-GraphConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of Entra ID audit events from Microsoft Graph." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } try { # Calculate 30 days ago from current date $thirtyDaysAgo = (Get-Date).AddDays(-30).Date # Warn if Hawk date range extends beyond available window if ($Hawk.StartDate -lt $thirtyDaysAgo) { Out-LogFile "Note: Entra ID audit logs are only available for the past 30 days. Earlier dates will be ignored." -Information } # Build filter string using 30-day limit $filterString = "activityDateTime ge $($thirtyDaysAgo.ToString('yyyy-MM-ddTHH:mm:ssZ')) and activityDateTime le $((Get-Date).ToString('yyyy-MM-ddTHH:mm:ssZ'))" Out-LogFile "Retrieving audit logs for the past 30 days" -Action # Get all audit logs for the date range [array]$auditLogs = Get-MgAuditLogDirectoryAudit -Filter $filterString -All if ($auditLogs.Count -gt 0) { Out-LogFile ("Found " + $auditLogs.Count + " audit log entries") -Information # Export the complete objects to both CSV and JSON $auditLogs | Out-MultipleFileType -FilePrefix "EntraIDAuditLogs" -csv -json } else { Out-LogFile "Get-HawkTenantEntraIDAuditLog completed successfully" -Information Out-LogFile "No audit logs found for the specified time period" -Action } } catch { Out-LogFile "Error retrieving Entra ID audit logs: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of Entra ID audit events from Microsoft Graph." -Information } Function Get-HawkTenantEntraIDUser { <# .SYNOPSIS This function will export all the Entra ID users (formerly Azure AD users). .DESCRIPTION This function exports all the Entra ID users to a .csv file, focusing on properties relevant for digital forensics and incident response. Properties include user identity, account status, and account dates. Note: SignInActivity requires additional AuditLog.Read.All permission and is currently commented out. .EXAMPLE PS C:\>Get-HawkTenantEntraIDUser Exports all Entra ID users with DFIR-relevant properties to .csv and .json files. .OUTPUTS EntraIDUsers.csv, EntraIDUsers.json .LINK https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=powershell .NOTES Updated to use Microsoft Graph SDK instead of AzureAD module. Properties selected for DFIR relevance. #> BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of users from Entra ID." -Action # Ensure we have a valid Graph connection Test-GraphConnection Send-AIEvent -Event "CmdRun" } PROCESS { # Get all users with specific properties needed for DFIR # -Property parameter optimizes API call to only retrieve needed fields $users = Get-MgUser -All -Property UserPrincipalName, # Primary user identifier DisplayName, # User's display name Id, # Unique object ID AccountEnabled, # Account status (active/disabled) CreatedDateTime, # Account creation timestamp DeletedDateTime, # Account deletion timestamp (if applicable) LastPasswordChangeDateTime, # Last password modification Mail | # Primary email address Select-Object UserPrincipalName, DisplayName, Id, AccountEnabled, CreatedDateTime, DeletedDateTime, LastPasswordChangeDateTime, # Only process if users were found if ($users) { # Sort by UPN and export to both CSV and JSON formats $users | Sort-Object -Property UserPrincipalName | Out-MultipleFileType -FilePrefix "EntraIDUsers" -csv -json } else { Out-LogFile "Get-HawkTenantEntraIDUser completed successfully" -Information Out-LogFile "No users found" -Action } } END { Out-LogFile "Completed collection of users from Entra ID." -Information } } Function Get-HawkTenantEXOAdmin{ <# .SYNOPSIS Exchange Online Administrator export. Must be connected to Exchange Online using the Connect-EXO cmdlet .DESCRIPTION After connecting to Exchange Online, this script will enumerate Exchange Online role group members and export the results to a .CSV file. Reviewing EXO admins can assist with determining who can change Exchange Online configurations and view .EXAMPLE PS C:\> Export-EXOAdmin -EngagementFolder foldername Exports Exchange Admins UserPrincipalName to .csv .OUTPUTS ExchangeOnlineAdministrators.csv/.json .NOTES #> BEGIN{ # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Out-LogFile "Initiating collection of Exchange Online Admins." -Action Test-EXOConnection Send-AIEvent -Event "CmdRun" } PROCESS{ $roles = foreach ($Role in Get-RoleGroup){ $ExchangeAdmins = Get-RoleGroupMember -Identity $Role.Identity | Select-Object -Property * foreach ($admin in $ExchangeAdmins){ if([string]::IsNullOrWhiteSpace($admin.WindowsLiveId)){ [PSCustomObject]@{ ExchangeAdminGroup = $Role.Name Members= $admin.DisplayName RecipientType = $admin.RecipientType } } else{ [PSCustomObject]@{ ExchangeAdminGroup = $Role.Name Members = $admin.WindowsLiveId RecipientType = $admin.RecipientType } } } } $roles | Out-MultipleFileType -FilePrefix "ExchangeOnlineAdministrators" -csv -json } END{ Out-Logfile "Completed exporting Exchange Online Admins." -Information } }#End Function Function Get-HawkTenantRBACChange { <# .SYNOPSIS Looks for any changes made to Role-Based Access Control (RBAC). .DESCRIPTION Searches the Unified Audit Logs for commands related to RBAC management including role, role assignment, role entry, role group, and management scope changes. This helps track administrative permission changes across the tenant. Uses Get-AllUnifiedAuditLogEntry to ensure complete retrieval of all audit records, handling pagination automatically for large result sets. The function searches for operations including: - Role management (New/Remove/Set-ManagementRole) - Role assignments (New/Remove/Set-ManagementRoleAssignment) - Management scopes (New/Remove/Set-ManagementScope) - Role entries (New/Remove/Set-ManagementRoleEntry) - Role groups (New/Remove/Set/Add/Remove-RoleGroup*) .OUTPUTS File: Simple_RBAC_Changes.csv Path: \Tenant Description: Simplified view of RBAC changes in CSV format File: RBAC_Changes.csv Path: \Tenant Description: Detailed RBAC changes in CSV format File: RBAC_Changes.json Path: \Tenant Description: Raw audit data in JSON format for detailed analysis .EXAMPLE Get-HawkTenantRBACChange Searches for and reports all RBAC changes in the tenant within the configured search window. #> [CmdletBinding()] param() # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } # Verify EXO connection and send telemetry Test-EXOConnection Send-AIEvent -Event "CmdRun" # Define the operations to search for [array]$RBACOperations = @( "New-ManagementRole", "Remove-ManagementRole", "New-ManagementRoleAssignment", "Remove-ManagementRoleAssignment", "Set-ManagementRoleAssignment", "New-ManagementScope", "Remove-ManagementScope", "Set-ManagementScope", "New-ManagementRoleEntry", "Remove-ManagementRoleEntry", "Set-ManagementRoleEntry", "New-RoleGroup", "Remove-RoleGroup", "Set-RoleGroup", "Add-RoleGroupMember", "Remove-RoleGroupMember" ) # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } Out-LogFile "Initiating collection of RBAC Changes from the UAL." -Action try { # Build search command for Get-AllUnifiedAuditLogEntry $searchCommand = "Search-UnifiedAuditLog -RecordType ExchangeAdmin -Operations " + "'$($RBACOperations -join "','")'" # Get all RBAC changes using Get-AllUnifiedAuditLogEntry [array]$RBACChanges = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand # Process results if any found if ($RBACChanges.Count -gt 0) { Out-LogFile ("Found " + $RBACChanges.Count + " changes made to Roles-Based Access Control") -Information # Parse changes using Get-SimpleUnifiedAuditLog $ParsedChanges = $RBACChanges | Get-SimpleUnifiedAuditLog # Output results if successfully parsed if ($ParsedChanges) { # Write simple format for easy analysis $ParsedChanges | Out-MultipleFileType -FilePrefix "Simple_RBAC_Changes" -csv -json # Write full audit logs for complete record $RBACChanges | Out-MultipleFileType -FilePrefix "RBAC_Changes" -csv -json } else { Out-LogFile "Error: Failed to parse RBAC changes" -isError } } else { Out-LogFile "Get-HawkTenantRbacChange completed successfully" -Information Out-LogFile "No RBAC changes found." -action } } catch { Out-LogFile "Error searching for RBAC changes: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } Out-LogFile "Completed collection of RBAC configuration changes from the UAL." -Information } Function Get-HawkTenantRiskDetections { <# .SYNOPSIS Retrieves risk detection events from Microsoft Entra ID. .DESCRIPTION Uses Microsoft Graph API to retrieve risk detection events from Microsoft Entra ID. The function gathers details about various types of risk detections, helping security teams identify and investigate potential security incidents. The function requires the following Microsoft Graph permissions: - IdentityRiskEvent.Read.All .EXAMPLE Get-HawkTenantRiskyDetections Retrieves all risk detections from Entra ID, including detection types and details. .OUTPUTS File: Risk_Detections.csv/.json Path: \Tenant Description: Risky detections for users in Entra ID .NOTES This function requires appropriate Graph API permissions to access risk detection data. Ensure your authenticated account has IdentityRiskEvent.Read.All permission. #> [CmdletBinding()] param() begin { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } # Test Graph connection and proper permissions Test-GraphConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of Risk Detection events from Entra ID." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } } process { try { # Get risk detections Out-LogFile "Retrieving risk detections" -Action $riskDetections = Get-MgRiskDetection -All if ($null -eq $riskDetections -or $riskDetections.Count -eq 0) { Out-LogFile "No risk detections found" -Information return } # Process and flatten risk detection data $processedDetections = Convert-HawkRiskData -RiskData $riskDetections Out-LogFile ("Total risk detections found: " + $processedDetections.Count) -Information # Export flattened data to CSV for analysis $processedDetections | Out-MultipleFileType -FilePrefix "Risk_Detections" -csv # Export original data to JSON to preserve structure $riskDetections | Out-MultipleFileType -FilePrefix "Risk_Detections" -json # Define risk level order $riskOrder = @{ 'high' = 1 'medium' = 2 'low' = 3 'none' = 4 } # Log summary of detections by risk level $riskLevels = $processedDetections | Group-Object -Property RiskLevel | Sort-Object -Property { $riskOrder[$_.Name] } foreach ($level in $riskLevels) { $capitalizedName = $level.Name.Substring(0, 1).ToUpper() + $level.Name.Substring(1).ToLower() Out-LogFile ("- $($level.Count) Risk Detections at Risk Level '${capitalizedName}'") -Information } # Split detections into confirmed compromised and other (high/medium/low) groups $confirmedCompromisedDetections = $processedDetections | Where-Object { $_.RiskState -eq 'confirmedCompromised' } $otherDetections = $processedDetections | Where-Object { $_.RiskState -ne 'confirmedCompromised' -and ($_.RiskLevel -eq 'high' -or $_.RiskLevel -eq 'medium' -or $_.RiskLevel -eq 'low') } # Process confirmed compromised risk detections if ($confirmedCompromisedDetections) { Out-LogFile "Found $($confirmedCompromisedDetections.Count) confirmed compromised risk detections" -Notice Out-LogFile "Details in _Investigate_Confirmed_Compromised_Risk_Detection files" -Notice $confirmedCompromisedDetections | Out-MultipleFileType -FilePrefix "_Investigate_Confirmed_Compromised_Risk_Detection" -csv -json -Notice } # Process other risk detections (combined high/medium/low) if ($otherDetections) { $highRisk = ($otherDetections | Where-Object { $_.RiskLevel -eq 'high' }).Count $mediumRisk = ($otherDetections | Where-Object { $_.RiskLevel -eq 'medium' }).Count $lowRisk = ($otherDetections | Where-Object { $_.RiskLevel -eq 'low' }).Count Out-LogFile "Found risk detections: $highRisk High, $mediumRisk Medium, $lowRisk Low" -Notice Out-LogFile "Details in _Investigate_Risk_Detection.csv/json" -Notice $otherDetections | Out-MultipleFileType -FilePrefix "_Investigate_Risk_Detection" -csv -json -Notice } } catch { Out-LogFile "Error retrieving risk detections: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } end { Out-LogFile "Completed collection of Risk Detection events from Entra ID." -Information } } Function Get-HawkTenantRiskyUsers { <# .SYNOPSIS Retrieves and analyzes users flagged as risky in Microsoft Entra ID. .DESCRIPTION Uses Microsoft Graph API to retrieve a list of users that have been flagged as risky in Microsoft Entra ID. The function analyzes user risk levels and states to identify potentially compromised accounts requiring immediate investigation. The function requires the following Microsoft Graph permissions: - IdentityRiskyUser.Read.All .EXAMPLE Get-HawkTenantRiskyUsers Retrieves all risky users from Entra ID, including their risk levels and risk states. High risk users are automatically flagged for investigation. .OUTPUTS File: RiskyUsers.csv/.json Path: \Tenant Description: All users currently flagged as risky in Entra ID File: _Investigate_HighRiskUsers.csv/.json Path: \Tenant Description: Users with high risk levels requiring immediate investigation .NOTES This function requires appropriate Graph API permissions to access risky user data. Ensure your authenticated account has IdentityRiskyUser.Read.All permission. #> [CmdletBinding()] param() begin { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } # Test Graph connection and proper permissions Test-GraphConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of Risky Users from Entra ID." -Action # Create tenant folder if it doesn't exist $TenantPath = Join-Path -Path $Hawk.FilePath -ChildPath "Tenant" if (-not (Test-Path -Path $TenantPath)) { New-Item -Path $TenantPath -ItemType Directory -Force | Out-Null } } process { try { # Get current risky users Out-LogFile "Retrieving current risky users" -Action $riskyUsers = Get-MgRiskyUser -All if ($null -eq $riskyUsers -or $riskyUsers.Count -eq 0) { Out-LogFile "No risky users found" -Information return } Out-LogFile ("Total risky users found: " + $riskyUsers.Count) -Information # Define risk level order for consistent sorting $riskOrder = @{ 'high' = 1 'medium' = 2 'low' = 3 'none' = 4 } # Log summary of users by risk level $riskLevels = $riskyUsers | Group-Object -Property RiskLevel | Sort-Object -Property { $riskOrder[$_.Name] } foreach ($level in $riskLevels) { $capitalizedName = $level.Name.Substring(0, 1).ToUpper() + $level.Name.Substring(1).ToLower() Out-LogFile ("- $($level.Count) users at Risk Level '${capitalizedName}'") -Information } # Export all risky users $riskyUsers | Out-MultipleFileType -FilePrefix "RiskyUsers" -csv -json # Group users by risk level and compromise state for investigation $riskyUserGroups = @{ Compromised = $riskyUsers | Where-Object { $_.RiskState -eq 'confirmedCompromised' } High = $riskyUsers | Where-Object { $_.RiskLevel -eq 'high' } Medium = $riskyUsers | Where-Object { $_.RiskLevel -eq 'medium' } Low = $riskyUsers | Where-Object { $_.RiskLevel -eq 'low' } } # Process compromised users if ($riskyUserGroups.Compromised) { Out-LogFile "Found $($riskyUserGroups.Compromised.Count) confirmed compromised accounts" -Notice Out-LogFile "Details in _Investigate_Compromised_Users files" -Notice $riskyUserGroups.Compromised | Out-MultipleFileType -FilePrefix "_Investigate_Compromised_Users" -json -Notice } # Combine High, Medium, and Low risk users into a single collection $nonCompromisedRiskUsers = @() if ($riskyUserGroups.High) { Out-LogFile ("Found " + $riskyUserGroups.High.Count + " High Risk users requiring immediate investigation") -Notice foreach ($user in $riskyUserGroups.High) { Out-LogFile ("High Risk user detected: $($user.UserPrincipalName)") -Notice Out-LogFile ("Risk Level: $($user.RiskLevel), Risk State: $($user.RiskState)") -Notice } $nonCompromisedRiskUsers += $riskyUserGroups.High } if ($riskyUserGroups.Medium) { Out-LogFile ("Found " + $riskyUserGroups.Medium.Count + " Medium Risk users requiring investigation") -Notice foreach ($user in $riskyUserGroups.Medium) { Out-LogFile ("Medium Risk user detected: $($user.UserPrincipalName)") -Notice Out-LogFile ("Risk Level: $($user.RiskLevel), Risk State: $($user.RiskState)") -Notice } $nonCompromisedRiskUsers += $riskyUserGroups.Medium } if ($riskyUserGroups.Low) { Out-LogFile ("Found " + $riskyUserGroups.Low.Count + " Low Risk users for review") -Notice foreach ($user in $riskyUserGroups.Low) { Out-LogFile ("Low Risk user detected: $($user.UserPrincipalName)") -Notice Out-LogFile ("Risk Level: $($user.RiskLevel), Risk State: $($user.RiskState)") -Notice } $nonCompromisedRiskUsers += $riskyUserGroups.Low } # Combine High, Medium, and Low risk users summary if ($nonCompromisedRiskUsers.Count -gt 0) { $highRisk = ($riskyUserGroups.High).Count $mediumRisk = ($riskyUserGroups.Medium).Count $lowRisk = ($riskyUserGroups.Low).Count Out-LogFile "Found risky users: $highRisk High, $mediumRisk Medium, $lowRisk Low" -Notice $nonCompromisedRiskUsers | Out-MultipleFileType -FilePrefix "_Investigate_Risky_Users" -csv -json -Notice } } catch { Out-LogFile "Error retrieving risky users: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } end { Out-LogFile "Completed collection of Risky Users from Entra ID." -Information } } Function Search-HawkTenantActivityByIP { <# .SYNOPSIS Gathers logon activity based on a submitted IP Address. .DESCRIPTION Pulls logon activity from the Unified Audit log based on a provided IP address. Processes the data to highlight successful logons and the number of users accessed by a given IP address. .PARAMETER IPaddress IP address to investigate .OUTPUTS File: All_Events.csv Path: \<IP> Description: All logon events File: All_Events.xml Path: \<IP>\xml Description: Client XML of all logon events File: Success_Events.csv Path: \<IP> Description: All logon events that were successful File: Unique_Users_Attempted.csv Path: \<IP> Description: List of Unique users that this IP tried to log into File: Unique_Users_Success.csv Path: \<IP> Description: Unique Users that this IP succesfully logged into File: Unique_Users_Success.xml Path: \<IP>\XML Description: Client XML of unique users the IP logged into .EXAMPLE Search-HawkTenantActivityByIP -IPAddress 10.234.20.12 Searches for all Logon activity from IP 10.234.20.12. #> param ( [parameter(Mandatory = $true)] [string]$IpAddress ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" Out-LogFile "Initiating collection of IP-based activity from the UAL." -Information # Replace an : in the IP address with . since : isn't allowed in a directory name $DirectoryName = $IpAddress.replace(":", ".") # Make sure we got only a single IP address if ($IpAddress -like "*,*") { Out-LogFile "Please provide a single IP address to search." -Information Write-Error -Message "Please provide a single IP address to search." -ErrorAction Stop } Out-LogFile ("Searching for events related to " + $IpAddress) -action # Gather all of the events related to these IP addresses [array]$ipevents = Get-AllUnifiedAuditLogEntry -UnifiedSearch ("Search-UnifiedAuditLog -IPAddresses " + $IPAddress ) # If we didn't get anything back log it if ($null -eq $ipevents) { Out-LogFile "Get-HawkTenantActivityByIP completed successfully" -Information Out-LogFile ("No IP logon events found for IP " + $IpAddress) -action } # If we did then process it else { # Expand out the Data and convert from JSON [array]$ipeventsexpanded = $ipevents | Select-object -ExpandProperty AuditData | ConvertFrom-Json Out-LogFile ("Found " + $ipeventsexpanded.count + " related to provided IP" ) -Information $ipeventsexpanded | Out-MultipleFileType -FilePrefix "All_Events" -csv -json -User $DirectoryName # Get the logon events that were a success [array]$successipevents = $ipeventsexpanded | Where-Object { $_.ResultStatus -eq "success" } Out-LogFile ("Found " + $successipevents.Count + " Successful logons related to provided IP") -Information $successipevents | Out-MultipleFileType -FilePrefix "Success_Events" -csv -json -User $DirectoryName # Select all unique users accessed by this IP [array]$uniqueuserlogons = Select-UniqueObject -ObjectArray $ipeventsexpanded -Property "UserID" Out-LogFile ("IP " + $ipaddress + " has tried to access " + $uniqueuserlogons.count + " users") -notice $uniqueuserlogons | Out-MultipleFileType -FilePrefix "Unique_Users_Attempted" -csv -json -User $DirectoryName -Notice if ($null -eq $uniqueuserlogonssuccess) { Out-LogFile ("No Successful Logon Events found for this IP: " + $IpAddress) } else { [array]$uniqueuserlogonssuccess = Select-UniqueObject -ObjectArray $successipevents -Property "UserID" Out-LogFile ("IP " + $IpAddress + " SUCCESSFULLY accessed " + $uniqueuserlogonssuccess.count + " users") -notice $uniqueuserlogonssuccess | Out-MultipleFileType -FilePrefix "Unique_Users_Success" -csv -json -User $DirectoryName -Notice } } Out-LogFile "Completed collection of IP-based activity from the UAL." -Information } Function Start-HawkTenantInvestigation { <# .SYNOPSIS Performs a comprehensive tenant-wide investigation using Hawk's automated data collection capabilities. .DESCRIPTION Start-HawkTenantInvestigation automates the collection and analysis of Microsoft 365 tenant-wide security data. It gathers information about tenant configuration, security settings, administrative changes, and potential security issues across the environment. The command can run in either interactive mode (default) or non-interactive mode. Interactive mode is used when no parameters are provided, while non-interactive mode is automatically enabled when any parameter is specified. In interactive mode, it prompts for necessary information such as date ranges and output location. Data collected includes: - Tenant configuration settings - eDiscovery configuration and logs - Administrative changes and permissions - Domain activities - Application consents and credentials - Exchange Online administrative activities All collected data is stored in a structured format for analysis, with suspicious findings highlighted for investigation. .PARAMETER StartDate The beginning date for the investigation period. When specified, must be used with EndDate. Cannot be later than EndDate and the date range cannot exceed 365 days. Providing this parameter automatically enables non-interactive mode. Format: MM/DD/YYYY .PARAMETER EndDate The ending date for the investigation period. When specified, must be used with StartDate. Cannot be in the future and the date range cannot exceed 365 days. Providing this parameter automatically enables non-interactive mode. Format: MM/DD/YYYY .PARAMETER DaysToLookBack Alternative to StartDate/EndDate. Specifies the number of days to look back from the current date. Must be between 1 and 365. Cannot be used together with StartDate. Providing this parameter automatically enables non-interactive mode. .PARAMETER FilePath The file system path where investigation results will be stored. Required in non-interactive mode. Must be a valid file system path. Providing this parameter automatically enables non-interactive mode. .PARAMETER SkipUpdate Switch to bypass the automatic check for Hawk module updates. Useful in automated scenarios or air-gapped environments. Providing this parameter automatically enables non-interactive mode. .PARAMETER Confirm Prompts you for confirmation before executing each investigation step. By default, confirmation prompts appear for operations that could collect sensitive data. .PARAMETER WhatIf Shows what would happen if the command runs. The command is not executed. Use this parameter to understand which investigation steps would be performed without actually collecting data. .OUTPUTS Creates multiple CSV and JSON files containing investigation results. All outputs are placed in the specified FilePath directory. See individual cmdlet help for specific output details. .EXAMPLE Start-HawkTenantInvestigation Runs a tenant investigation in interactive mode, prompting for date range and output location. .EXAMPLE Start-HawkTenantInvestigation -DaysToLookBack 30 -FilePath "C:\Investigation" Performs a tenant investigation looking back 30 days from today, saving results to C:\Investigation. Runs in non-interactive mode because parameters were specified. .EXAMPLE Start-HawkTenantInvestigation -StartDate "01/01/2024" -EndDate "01/31/2024" -FilePath "C:\Investigation" -SkipUpdate Investigates tenant activity for January 2024, saving results to C:\Investigation. Skips the update check. Runs in non-interactive mode because parameters were specified. .EXAMPLE Start-HawkTenantInvestigation -WhatIf Shows what investigation steps would be performed without actually executing them. Useful for understanding the investigation process or validating parameters. .LINK https://hawkforensics.io .LINK https://github.com/T0pCyber/hawk #> [CmdletBinding(SupportsShouldProcess)] param ( [DateTime]$StartDate, [DateTime]$EndDate, [int]$DaysToLookBack, [string]$FilePath, [switch]$SkipUpdate ) begin { $NonInteractive = Test-HawkNonInteractiveMode -PSBoundParameters $PSBoundParameters if ($NonInteractive) { $processedDates = Test-HawkDateParameter -PSBoundParameters $PSBoundParameters -StartDate $StartDate -EndDate $EndDate -DaysToLookBack $DaysToLookBack $StartDate = $processedDates.StartDate $EndDate = $processedDates.EndDate # Now call validation with updated StartDate/EndDate $validation = Test-HawkInvestigationParameter ` -StartDate $StartDate -EndDate $EndDate ` -DaysToLookBack $DaysToLookBack -FilePath $FilePath -NonInteractive if (-not $validation.IsValid) { foreach ($error in $validation.ErrorMessages) { Stop-PSFFunction -Message $error -EnableException $true } } try { Initialize-HawkGlobalObject -StartDate $StartDate -EndDate $EndDate ` -DaysToLookBack $DaysToLookBack -FilePath $FilePath ` -SkipUpdate:$SkipUpdate -NonInteractive:$NonInteractive } catch { Stop-PSFFunction -Message "Failed to initialize Hawk: $_" -EnableException $true } } } process { if (Test-PSFFunctionInterrupt) { return } # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } $investigationStartTime = Get-Date Out-LogFile "Starting Tenant Investigation." -action Send-AIEvent -Event "CmdRun" # Wrap operations in ShouldProcess checks if ($PSCmdlet.ShouldProcess("Tenant Configuration", "Get configuration data")) { Write-Output "" Out-LogFile "Running Get-HawkTenantConfiguration." -action Get-HawkTenantConfiguration } if ($PSCmdlet.ShouldProcess("EDiscovery Configuration", "Get eDiscovery configuration")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEDiscoveryConfiguration." -action Get-HawkTenantEDiscoveryConfiguration } if ($PSCmdlet.ShouldProcess("EDiscovery Logs", "Get eDiscovery logs")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEDiscoveryLog." -action Get-HawkTenantEDiscoveryLog } if ($PSCmdlet.ShouldProcess("Admin Inbox Rule Creation Audit Log", "Search Admin Inbox Rule Creation")) { Write-Output "" Out-LogFile "Running Get-HawkTenantAdminInboxRuleCreation." -action Get-HawkTenantAdminInboxRuleCreation } if ($PSCmdlet.ShouldProcess("Admin Inbox Rule Modification Audit Log", "Search Admin Inbox Rule Modification")) { Write-Output "" Out-LogFile "Running Get-HawkTenantInboxRuleModification." -action Get-HawkTenantAdminInboxRuleModification } if ($PSCmdlet.ShouldProcess("Admin Inbox Rule Removal Audit Log", "Search Admin Inbox Rule Removal")) { Write-Output "" Out-LogFile "Running Get-HawkTenantAdminInboxRuleRemoval." -action Get-HawkTenantAdminInboxRuleRemoval } if ($PSCmdlet.ShouldProcess("Admin Inbox Rule Permission Change Audit Log", "Search Admin Inbox Permission Changes")) { Write-Output "" Out-LogFile "Running Get-HawkTenantAdminMailboxPermissionChange." -action Get-HawkTenantAdminMailboxPermissionChange } if ($PSCmdlet.ShouldProcess("Admin Email Forwarding Change Change Audit Log", "Search Admin Email Forwarding Changes")) { Write-Output "" Out-LogFile "Running Get-HawkTenantAdminEmailForwardingChange." -action Get-HawkTenantAdminEmailForwardingChange } if ($PSCmdlet.ShouldProcess("Domain Activity", "Get domain activity")) { Write-Output "" Out-LogFile "Running Get-HawkTenantDomainActivity." -action Get-HawkTenantDomainActivity } if ($PSCmdlet.ShouldProcess("RBAC Changes", "Get RBAC changes")) { Write-Output "" Out-LogFile "Running Get-HawkTenantRBACChange." -action Get-HawkTenantRBACChange } if ($PSCmdlet.ShouldProcess("Entra ID Audit Log", "Get Entra ID audit logs")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEntraIDAuditLog." -action Get-HawkTenantEntraIDAuditLog } if ($PSCmdlet.ShouldProcess("Entra ID App Audit Log", "Get Entra ID app audit logs")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEntraIDAppAuditLog." -action Get-HawkTenantEntraIDAppAuditLog } if ($PSCmdlet.ShouldProcess("Exchange Admins", "Get Exchange admin list")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEXOAdmin." -action Get-HawkTenantEXOAdmin } if ($PSCmdlet.ShouldProcess("Consent Grants", "Get consent grants")) { Write-Output "" Out-LogFile "Running Get-HawkTenantConsentGrant." -action Get-HawkTenantConsentGrant } if ($PSCmdlet.ShouldProcess("Risky Users", "Get Entra ID Risky Users")) { Write-Output "" Out-LogFile "Running Get-HawkTenantRiskyUsers." -action Get-HawkTenantRiskyUsers } if ($PSCmdlet.ShouldProcess("Risk Detections", "Get Entra ID Risk Detections")) { Write-Output "" Out-LogFile "Running Get-HawkTenantRiskDetections." -action Get-HawkTenantRiskDetections } if ($PSCmdlet.ShouldProcess("Entra ID Admins", "Get Entra ID admin list")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEntraIDAdmin." -action Get-HawkTenantEntraIDAdmin } if ($PSCmdlet.ShouldProcess("App and SPN Credentials", "Get credential details")) { Write-Output "" Out-LogFile "Running Get-HawkTenantAppAndSPNCredentialDetail." -action Get-HawkTenantAppAndSPNCredentialDetail } if ($PSCmdlet.ShouldProcess("Entra ID Users", "Get Entra ID user list")) { Write-Output "" Out-LogFile "Running Get-HawkTenantEntraIDUser." -action Get-HawkTenantEntraIDUser } } end { # Calculate end time and display summary $investigationEndTime = Get-Date Write-HawkInvestigationSummary -StartTime $investigationStartTime -EndTime $investigationEndTime -InvestigationType 'Tenant' } } Function Get-HawkUserAdminAudit { <# .SYNOPSIS Searches the Unified Audit logs for any commands that were run against the provided user object. .DESCRIPTION Searches the Unified Audit logs for any commands that were run against the provided user object. Uses Get-AllUnifiedAuditLogEntry to ensure complete retrieval of all audit records within the specified search period, handling pagination and large result sets automatically. .PARAMETER UserPrincipalName UserPrincipalName of the user you're investigating. Can be a single UPN, comma-separated list, or array of objects containing UPNs. .OUTPUTS File: Simple_User_Changes.csv Path: \<user> Description: All cmdlets that were run against the user in a simple format. File: User_Changes.csv Path: \<user> Description: Raw data of all changes made to the user. .EXAMPLE Get-HawkUserAdminAudit -UserPrincipalName user@company.com Gets all changes made to user@company.com and outputs them to the csv and json files. .EXAMPLE Get-HawkUserAdminAudit -UserPrincipalName (Get-Mailbox -Filter {CustomAttribute1 -eq "VIP"}) Gets admin audit data for all mailboxes with CustomAttribute1 set to "VIP". #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Get the mailbox name since that is what we store in the admin audit log $MailboxName = (Get-Mailbox -Identity $User).Name Out-LogFile "Initiating collection of admin audit events for $User from the UAL." -Action try { # Build search command for Get-AllUnifiedAuditLogEntry $searchCommand = "Search-UnifiedAuditLog -UserIds $User -RecordType ExchangeAdmin -Operations '*'" # Get all changes for this user using Get-AllUnifiedAuditLogEntry [array]$UserChanges = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand # If there are any results process and output them if ($UserChanges.Count -gt 0) { Out-LogFile ("Found " + $UserChanges.Count + " changes made to this user") -Information # Get the user's output folder path $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User # Ensure user folder exists if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } # Parse and format the changes using Get-SimpleUnifiedAuditLog $ParsedChanges = $UserChanges | Get-SimpleUnifiedAuditLog # Output the processed results if ($ParsedChanges) { $ParsedChanges | Out-MultipleFileType -FilePrefix "Simple_User_Changes" -csv -json -User $User } # Output the raw changes $UserChanges | Out-MultipleFileType -FilePrefix "User_Changes" -csv -json -User $User } else { Out-LogFile "Get-HawkUserAdminAudit completed successfully" -Information Out-LogFile "No User Changes found." -action } } catch { Out-LogFile "Error processing audit logs for $User : $_" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } finally { Out-LogFile "Completed collection of admin audit events for $User from the UAL." -Information } } } Function Get-HawkUserAutoReply { <# .SYNOPSIS Pulls AutoReply Configuration for the specified user. .DESCRIPTION Gathers AutoReply configuration for the provided users. Looks for AutoReplyState of Enabled and exports the config. .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: AutoReply.txt Path: \<User> Description: AutoReplyConfiguration for the user. .EXAMPLE Get-HawkUserAutoReply -UserPrincipalName user@contoso.com Pulls AutoReplyConfiguration for user@contoso.com and looks for AutoReplyState Enabled. .EXAMPLE Get-HawkUserAutoReply -UserPrincipalName (get-mailbox -Filter {Customattribute1 -eq "C-level"}) Gathers AutoReplyConfiguration for all users who have "C-Level" set in CustomAttribute1 #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Get Autoreply Configuration Out-LogFile "Initiating collection of auto-reply configuration for $User from Exchange Online." -Action $AutoReply = Get-MailboxAutoReplyConfiguration -Identity $User # Check if the Autoreply is Disabled if ($AutoReply.AutoReplyState -eq 'Disabled') { Out-LogFile "AutoReply is not enabled or not configured." -Information } # Output Enabled AutoReplyConfiguration to a generic txt else { $AutoReply | Out-MultipleFileType -FilePreFix "AutoReply" -User $user -txt } Out-LogFile "Completed collection of auto-reply configuration for $User from Exchange Online." -Information } } Function Get-HawkUserConfiguration { <# .SYNOPSIS Gathers baseline information about the provided user. .DESCRIPTION Gathers and records baseline information about the provided user. * Get-EXOMailbox * Get-EXOMailboxStatistics * Get-EXOMailboxFolderStatistics * Get-CASMailbox .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: Mailbox_Info.txt Path: \<User> Description: Output of Get-EXOMailbox for the user File: Mailbox_Statistics.txt Path : \<User> Description: Output of Get-EXOMailboxStatistics for the user File: Mailbox_Folder_Statistics.txt Path : \<User> Description: Output of Get-EXOMailboxFolderStatistics for the user File: CAS_Mailbox_Info.txt Path : \<User> Description: Output of Get-CasMailbox for the user .EXAMPLE Get-HawkUserConfiguration -user bsmith@contoso.com Gathers the user configuration for bsmith@contoso.com .EXAMPLE Get-HawkUserConfiguration -UserPrincipalName (Get-EXOMailbox -Filter {Customattribute1 -eq "C-level"}) Gathers the user configuration for all users who have "C-Level" set in CustomAttribute1 #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName Out-LogFile "Initiating collection of mailbox configuration for $User from Exchange Online." -Action #Gather mailbox information $mbx = Get-EXOMailbox -Identity $user # Test to see if we have an archive and include that info as well if (!($null -eq $mbx.archivedatabase)) { Get-EXOMailboxStatistics -identity $user -Archive | Out-MultipleFileType -FilePrefix "Mailbox_Archive_Statistics" -user $user -txt } $mbx | Out-MultipleFileType -FilePrefix "Mailbox_Info" -User $User -txt Get-EXOMailboxStatistics -Identity $user | Out-MultipleFileType -FilePrefix "Mailbox_Statistics" -User $User -txt Get-EXOMailboxFolderStatistics -identity $user | Out-MultipleFileType -FilePrefix "Mailbox_Folder_Statistics" -User $User -txt # Gather cas mailbox sessions Out-LogFile "Gathering CAS Mailbox Information" -action Get-EXOCasMailbox -identity $user | Out-MultipleFileType -FilePrefix "CAS_Mailbox_Info" -User $User -txt Out-LogFile "Completed collection of mailbox configuration for $User from Exchange Online." -Information } } Function Get-HawkUserEmailForwarding { <# .SYNOPSIS Pulls mail forwarding configuration for a specified user. .DESCRIPTION Pulls the values of ForwardingSMTPAddress and ForwardingAddress to see if the user has these configured. .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: _Investigate_Users_WithForwarding.csv Path: \ Description: All users that are found to have forwarding configured. File: User_ForwardingReport.csv Path: \ Description: Mail forwarding configuration for all searched users; even if null. File: ForwardingReport.csv Path: \<user> Description: Forwarding confiruation of the searched user. .EXAMPLE Get-HawkUserEmailForwarding -UserPrincipalName user@contoso.com Gathers possible email forwarding configured on the user. .EXAMPLE Get-HawkUserEmailForwarding -UserPrincipalName (get-mailbox -Filter {Customattribute1 -eq "C-level"}) Gathers possible email forwarding configured for all users who have "C-Level" set in CustomAttribute1 #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Looking for email forwarding stored in AD Out-LogFile "Initiating collection of email forwarding configuration for $User from Exchange Online." -Action $mbx = Get-Mailbox -identity $User # Check if forwarding is configured by user or admin if ([string]::IsNullOrEmpty($mbx.ForwardingSMTPAddress) -and [string]::IsNullOrEmpty($mbx.ForwardingAddress)) { Out-LogFile "Get-HawkUserEmailForwarding completed successfully" -Information Out-LogFile "No forwarding configuration found" -action } # If populated report it and add to a CSV file of positive finds else { Out-LogFile "Found email forwarding configured for $User" -Notice Out-LogFile "Please verify this activity is legitimate." -Notice $mbx | Select-Object DisplayName, UserPrincipalName, PrimarySMTPAddress, ForwardingSMTPAddress, ForwardingAddress, DeliverToMailboxAndForward, WhenChangedUTC | Out-MultipleFileType -FilePreFix "_Investigate_Users_WithForwarding" -append -user $user -csv -json -Notice } # Add all users searched to a generic output $mbx | Select-Object DisplayName, UserPrincipalName, PrimarySMTPAddress, ForwardingSMTPAddress, ForwardingAddress, DeliverToMailboxAndForward, WhenChangedUTC | Out-MultipleFileType -FilePreFix "User_ForwardingReport" -append -csv -json # Also add to an output specific to this user $mbx | Select-Object DisplayName, UserPrincipalName, PrimarySMTPAddress, ForwardingSMTPAddress, ForwardingAddress, DeliverToMailboxAndForward, WhenChangedUTC | Out-MultipleFileType -FilePreFix "ForwardingReport" -user $user -csv -json Out-LogFile "Completed collection of email forwarding configuration for $User from Exchange Online." -Information } } Function Get-HawkUserEntraIDSignInLog { <# .SYNOPSIS Retrieves Microsoft Entra ID sign-in logs for specified users from the most recent 14 days. .DESCRIPTION This function retrieves sign-in logs from Microsoft Entra ID (formerly Azure AD) for specified users. Due to Microsoft Graph API limitations, this function can only retrieve logs from the past 14 days or less. If you specify a longer time range, the function will automatically use only the most recent 14 days of data from your specified end date. The function analyzes sign-in patterns to identify: - Risky sign-ins based on Microsoft's detection - Both real-time and aggregated risk levels - Risk level distributions and trends .PARAMETER UserPrincipalName Specifies which users to investigate. Accepts: - Single user: "user@contoso.com" - Multiple users: @("user1@contoso.com", "user2@contoso.com") - Object array: (Get-Mailbox -Filter {CustomAttribute1 -eq "VIP"}) .OUTPUTS Creates the following files in the user's output directory: - Entra_Sign_In_Log_[user].csv - All sign-in data in CSV format - Entra_Sign_In_Log_[user].json - All sign-in data in JSON format - _Investigate_Entra_Sign_In_Log_$User - Only sign in logs for those with an associated risk level. Note: Only contains data from the most recent 14 days relative to the specified end date. .EXAMPLE Get-HawkUserEntraIDSignInLog -UserPrincipalName "user@contoso.com" Gets the most recent 14 days of sign-in logs for user@contoso.com. .EXAMPLE Get-HawkUserEntraIDSignInLog -UserPrincipalName (Get-Mailbox -Filter {CustomAttribute1 -eq "VIP"}) Gets the most recent 14 days of sign-in logs for all VIP users. .NOTES Due to Microsoft Graph API limitations: - Can only retrieve up to 14 days of historical data - Longer date ranges will be automatically adjusted - Data outside the 14-day window is not available through this API #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) BEGIN { if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-GraphConnection Send-AIEvent -Event "CmdRun" [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName # Track overall success $global:processSuccess = $true # Calculate date range - limit to 2 weeks from end date $endDateUtc = $Hawk.EndDate.ToUniversalTime() $twoWeeksAgo = $endDateUtc.AddDays(-14) $requestedStart = $Hawk.StartDate.ToUniversalTime() # Compare dates manually since PowerShell doesn't have DateTime.Max $effectiveStartDate = if ($requestedStart -gt $twoWeeksAgo) { $requestedStart } else { $twoWeeksAgo } } PROCESS { foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName try { Out-LogFile "Initiating collection of sign-in logs for $User from Entra ID." -Action # Notify user about 14-day limit and any date adjustment Out-LogFile "Hawk Entra ID Sign-in logs is limited to the most recent 14 days" -Information if ($Hawk.StartDate.ToUniversalTime() -lt $twoWeeksAgo) { Out-LogFile "Your requested date range exceeds this limit. Data will only be available from $($effectiveStartDate.ToString('yyyy-MM-dd')) to $($endDateUtc.ToString('yyyy-MM-dd'))" -Information } else { Out-LogFile "Retrieving data from $($effectiveStartDate.ToString('yyyy-MM-dd')) to $($endDateUtc.ToString('yyyy-MM-dd'))" -Information } # Use adjusted date range and ensure we have valid dates $startDateUtc = if ($effectiveStartDate) { $effectiveStartDate.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $twoWeeksAgo.ToString('yyyy-MM-ddTHH:mm:ssZ') } $endDateUtc = $Hawk.EndDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') # Combine user and date filters $filter = "userPrincipalName eq '$User' and createdDateTime ge $startDateUtc and createdDateTime le $endDateUtc" $processedCount = 0 $signInLogs = Get-MgAuditLogSignIn -Filter $filter -All -ErrorAction Stop foreach ($log in $signInLogs) { $processedCount++ if ($processedCount % 100 -eq 0) { Write-Progress -Activity "Retrieving Entra Sign-in Logs" ` -Status "Processed $processedCount logs" ` -PercentComplete -1 } } Write-Progress -Activity "Retrieving Entra Sign-in Logs" -Completed if ($signInLogs.Count -gt 0) { Out-LogFile ("Retrieved " + $signInLogs.Count + " sign-in log entries for " + $User) -Information # Write all logs to CSV/JSON $signInLogs | Out-MultipleFileType -FilePrefix "Entra_Sign_In_Log_$User" -User $User -csv -json # Check for risky sign-ins $riskySignIns = $signInLogs | Where-Object { $_.RiskLevelDuringSignIn -in @('high', 'medium', 'low') -or $_.RiskLevelAggregated -in @('high', 'medium', 'low') } if ($riskySignIns.Count -gt 0) { # Flag for investigation Out-LogFile ("Found " + $riskySignIns.Count + " risky sign-ins for " + $User) -Notice # Export risky sign-ins for investigation $riskySignIns | Out-MultipleFileType -FilePrefix "_Investigate_Entra_Sign_In_Log_$User" -User $User -csv -json -Notice # Group and report risk levels $duringSignIn = $riskySignIns | Group-Object -Property RiskLevelDuringSignIn | Where-Object { $_.Name -in @('high', 'medium', 'low') } foreach ($risk in $duringSignIn) { Out-LogFile ("Found " + $risk.Count + " sign-ins with risk level during sign-in: " + $risk.Name) -Notice } $aggregated = $riskySignIns | Group-Object -Property RiskLevelAggregated | Where-Object { $_.Name -in @('high', 'medium', 'low') } foreach ($risk in $aggregated) { Out-LogFile ("Found " + $risk.Count + " sign-ins with aggregated risk level: " + $risk.Name) -Notice } Out-LogFile ("Review _Investigate_Entra_Sign_In_Log_$User.csv/json for complete details") -Notice } } else { Out-LogFile ("No sign-in logs found for " + $User + " in the specified time period") -Information } } catch { $global:processSuccess = $false Out-LogFile ("Error retrieving sign-in logs for " + $User + " : " + $_.Exception.Message) -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } # Only show completion message if successful if ($global:processSuccess) { Out-LogFile "Completed collection of Entra sign-in logs for $User from Entra ID." -Information } } } END { Remove-Variable -Name processSuccess -Scope Global -ErrorAction SilentlyContinue } } Function Get-HawkUserExchangeSearchQuery { <# .SYNOPSIS This will export SearchQueryInitiatedExchange operations from the Unified Audit Log (UAL). Must be connected to Exchange Online using the Connect-EXO or Connect-ExchangeOnline module. M365 E5 or G5 license is required for this function to work. This telemetry will ONLY be availabe if Advanced Auditing is enabled for the M365 user. .DESCRIPTION This function queries for searches performed in Exchange, providing visibility into what users are searching for and potential indications of insider threats or data exploration during incidents. .PARAMETER UserPrincipalName Specific user(s) to be investigated .EXAMPLE Get-HawkUserExchangeSearchQuery -UserPrincipalName bsmith@contoso.com Returns search queries from Unified Audit Log (UAL) that correspond to the UserPrincipalName that is provided .OUTPUTS ExchangeSearchQueries_bsmith@contoso.com.csv /json Simple_ExchangeSearchQueries_bsmith@contoso.com.csv/json .LINK https://www.microsoft.com/security/blog/2020/12/21/advice-for-incident-responders-on-recovery-from-systemic-identity-compromises/ .NOTES "Operation Properties" and "Folders" will return "System.Object" as they are nested JSON within the AuditData field. You will need to conduct individual log pull and review via PowerShell or other SIEM to determine values for those fields. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" }#End Begin PROCESS { #Verify UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($UserObject in $UserArray) { [string]$User = $UserObject.UserPrincipalName Out-LogFile "Initiating collection of Exchange Search queries for $User from the UAL." -Action Out-LogFile "Please be patient, this can take a while..." -Information # Verify that user has operation enabled for auditing. Otherwise, move onto next user. if (Test-OperationEnabled -User $User -Operation 'SearchQueryInitiated') { Out-LogFile "Operation 'SearchQueryInitiated' verified enabled for $User." -Information try { #Retrieve all audit data for Exchange search queries $SearchCommand = "Search-UnifiedAuditLog -Operations 'SearchQueryInitiatedExchange' -UserIds $User" $ExchangeSearches = Get-AllUnifiedAuditLogEntry -UnifiedSearch $SearchCommand if ($ExchangeSearches.Count -gt 0) { #Define output directory path for user $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User #Create user directory if it doesn't already exist if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } #Compress raw data into more simple view $ExchangeSearchesSimple = $ExchangeSearches | Get-SimpleUnifiedAuditLog #Export both raw and simplistic views to specified user's folder $ExchangeSearches | Select-Object -ExpandProperty AuditData | Convertfrom-Json | Out-MultipleFileType -FilePrefix "ExchangeSearchQueries_$User" -User $User -csv -json $ExchangeSearchesSimple | Out-MultipleFileType -FilePrefix "Simple_ExchangeSearchQueries_$User" -User $User -csv -json } else { Out-LogFile "No Exchange Search Queries found for $User." -Information } } catch { Out-LogFile "Error processing Exchange Search Queries for $User : $_" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } else { Out-LogFile "Operation 'SearchQueryInitiated' is not enabled for $User." -Information Out-LogFile "No data recorded for $User." -Information } Out-LogFile "Completed collection of Exchange Search queries for $User from the UAL." -Information } }#End Process END { }#End End } # Gets user inbox rules and looks for Investigate rules Function Get-HawkUserInboxRule { <# .SYNOPSIS Exports inbox rules for the specified user. .DESCRIPTION Gathers inbox rules for the provided uers. Looks for rules that forward or delete email and flag them for follow up .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: _Investigate_InboxRules.csv Path: \<User> Description: Inbox rules that delete or forward messages. File: InboxRules.csv Path: \<User> Description: All inbox rules that were found for the user. File: All_InboxRules.csv Path: \ Description: All users inbox rules. .EXAMPLE Get-HawkUserInboxRule -UserPrincipalName user@contoso.com Pulls all inbox rules for user@contoso.com and looks for Investigate rules. .EXAMPLE Get-HawkUserInboxRule -UserPrincipalName (get-mailbox -Filter {Customattribute1 -eq "C-level"}) Gathers inbox rules for all users who have "C-Level" set in CustomAttribute1 #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Get Inbox rules Out-LogFile "Initiating collection of Exchange Inbox Rules for $User from Exchange Online." -Action $InboxRules = Get-InboxRule -mailbox $User if ($null -eq $InboxRules) { Out-LogFile "No Inbox Rules found for $user" -action } else { # Track if we found any suspicious rules $foundSuspiciousRules = $false # If the rules contains one of a number of known suspicious properties flag them foreach ($Rule in $InboxRules) { # Set our flag to false $Investigate = $false # Evaluate each of the properties that we know bad actors like to use and flip the flag if needed if ($Rule.DeleteMessage -eq $true) { $Investigate = $true } if (!([string]::IsNullOrEmpty($Rule.ForwardAsAttachmentTo))) { $Investigate = $true } if (!([string]::IsNullOrEmpty($Rule.ForwardTo))) { $Investigate = $true } if (!([string]::IsNullOrEmpty($Rule.RedirectTo))) { $Investigate = $true } # If we have set the Investigate flag then output to investigation file if ($Investigate -eq $true) { $foundSuspiciousRules = $true # Description is multiline $Rule.Description = $Rule.Description.replace("`r`n", " ").replace("`t", "") $Rule | Out-MultipleFileType -FilePreFix "_Investigate_InboxRules" -user $user -csv -json -append } } # Output notice only once if suspicious rules were found if ($foundSuspiciousRules) { $suspiciousRuleCount = ($InboxRules | Where-Object { $_.DeleteMessage -eq $true -or ![string]::IsNullOrEmpty($_.ForwardAsAttachmentTo) -or ![string]::IsNullOrEmpty($_.ForwardTo) -or ![string]::IsNullOrEmpty($_.RedirectTo) }).Count Out-LogFile "Found $suspiciousRuleCount inbox rules requiring investigation for $User" -Notice Out-LogFile "Please verify this activity is legitimate. Details in _Investigate_InboxRules.csv/json" -Notice } # Description is multiline $inboxrulesRawDescription = $InboxRules $InboxRules = New-Object -TypeName "System.Collections.ArrayList" $inboxrulesRawDescription | ForEach-Object { $_.Description = $_.Description.Replace("`r`n", " ").replace("`t", "") $null = $InboxRules.Add($_) } # Output all of the inbox rules to a generic csv $InboxRules | Out-MultipleFileType -FilePreFix "InboxRules" -User $user -csv -json # Add all of the inbox rules to a generic collection file $InboxRules | Out-MultipleFileType -FilePrefix "All_InboxRules" -User $user -csv -json -Append } # Get any Sweep Rules # Suggested by Adonis Sardinas Out-LogFile ("Gathering Sweep Rules: " + $User) -action $SweepRules = Get-SweepRule -Mailbox $User if ($null -eq $SweepRules) { Out-LogFile "No Sweep Rules found" -Information } else { # Output all rules to a user CSV $SweepRules | Out-MultipleFileType -FilePreFix "SweepRules" -user $User -csv -json # Add any found to the whole tenant list $SweepRules | Out-MultipleFileType -FilePreFix "All_SweepRules" -csv -json -append } Out-LogFile "Completed collection of Exchange Inbox Rules for $User from Exchange Online." -Information } } function Get-HawkUserMailboxAuditing { <# .SYNOPSIS Gathers Mailbox Audit data if enabled for the user. .DESCRIPTION Retrieves mailbox audit logs from Microsoft 365 Unified Audit Log, focusing on mailbox content access and operations. This function replaces the deprecated Search-MailboxAuditLog cmdlet with modern UAL-based auditing. Migration Changes: - Old: Used Search-MailboxAuditLog for direct mailbox audit log access - New: Uses Search-UnifiedAuditLog with separate collection of: * ExchangeItem records (item-level operations) * ExchangeItemGroup records (access patterns) The new implementation provides: - Improved visibility into mailbox item access patterns - More consistent data collection across Exchange Online - Automatic pagination for large result sets - Integration with Microsoft 365 compliance center - Separated output files for better data analysis Note: Administrative actions on mailboxes (like granting permissions) are tracked by Get-HawkUserAdminAudit instead of this function. .PARAMETER UserPrincipalName Single UPN of a user, comma-separated list of UPNs, or array of objects that contain UPNs. .OUTPUTS ExchangeItem Records: File: ExchangeItem_Simple_{User}.csv/.json Path: \<User> Description: Flattened item-level operations data in CSV and JSON formats File: ExchangeItem_Logs_{User}.csv/.json Path: \<User> Description: Raw item-level operations data in CSV and JSON formats ExchangeItemGroup Records: File: ExchangeItemGroup_Simple_{User}.csv/.json Path: \<User> Description: Flattened access pattern data in CSV and JSON formats File: ExchangeItemGroup_Logs_{User}.csv/.json Path: \<User> Description: Raw access pattern data in CSV and JSON formats .EXAMPLE Get-HawkUserMailboxAuditing -UserPrincipalName user@contoso.com Search for all Mailbox Audit logs from user@contoso.com, creating separate files for item operations and access patterns, each with both raw and processed formats. .EXAMPLE Get-HawkUserMailboxAuditing -UserPrincipalName (Get-Mailbox -Filter {CustomAttribute1 -eq "C-level"}) Search for all Mailbox Audit logs for all users who have "C-Level" set in CustomAttribute1, creating separate output files for each user's item operations and access patterns. .NOTES In older versions of Exchange Online, Search-MailboxAuditLog provided direct access to mailbox audit data. This has been replaced by the Unified Audit Log which provides a more comprehensive and consistent view of mailbox activities through separate record types: - ExchangeItem: Tracks specific operations on items - ExchangeItemGroup: Tracks access patterns and aggregated activity Each record type is processed separately and output in multiple formats to support different analysis needs: - Simple (flattened) formats for easy analysis - Raw formats for detailed investigation - JSON dumps for programmatic processing #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName Out-LogFile "Initiating collection of Mailbox Audit Logs for $User from the UAL." -Action # Test if mailbox auditing is enabled $mbx = Get-Mailbox -Identity $User if ($mbx.AuditEnabled -eq $true) { Out-LogFile "Mailbox Auditing is enabled." -Information try { # Get the user's folder path $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } # Process ExchangeItem records Out-LogFile "Searching Unified Audit Log for ExchangeItem events." -action $searchCommand = "Search-UnifiedAuditLog -UserIds $User -RecordType ExchangeItem" $itemLogs = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand if ($itemLogs.Count -gt 0) { Out-LogFile ("Found " + $itemLogs.Count + " ExchangeItem events.") -Information # Process and output flattened data $ParsedItemLogs = $itemLogs | Get-SimpleUnifiedAuditLog if ($ParsedItemLogs) { $ParsedItemLogs | Out-MultipleFileType -FilePrefix "ExchangeItem_Simple" -csv -json -User $User } # Output raw data $itemLogs | Out-MultipleFileType -FilePrefix "ExchangeItem_Logs" -csv -json -User $User } else { Out-LogFile "ExchangeItem event search completed successfully" -Information Out-LogFile "No ExchangeItem events found." -Action } # Process ExchangeItemGroup records Out-LogFile "Searching Unified Audit Log for ExchangeItemGroup events." -action Out-LogFile "Please be patient, this can take a while..." -action $searchCommand = "Search-UnifiedAuditLog -UserIds $User -RecordType ExchangeItemGroup" $groupLogs = Get-AllUnifiedAuditLogEntry -UnifiedSearch $searchCommand if ($groupLogs.Count -gt 0) { Out-LogFile ("Found " + $groupLogs.Count + " ExchangeItemGroup events.") -Information Out-LogFile "Processing all ExchangeItemGroup events, this can take a while..." -action # Process and output flattened data $ParsedGroupLogs = $groupLogs | Get-SimpleUnifiedAuditLog if ($ParsedGroupLogs) { $ParsedGroupLogs | Out-MultipleFileType -FilePrefix "ExchangeItemGroup_Simple" -csv -json -User $User } # Output raw data $groupLogs | Out-MultipleFileType -FilePrefix "ExchangeItemGroup_Logs" -csv -json -User $User } else { Out-LogFile "ExchangeItemGroup search completed successfully" -Information Out-LogFile "No ExchangeItemGroup events found." -action } # Summary logging $totalEvents = ($itemLogs.Count + $groupLogs.Count) Out-LogFile "Completed processing $totalEvents total events." -Information } catch { Out-LogFile "Error retrieving audit logs: $($_.Exception.Message)" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } else { Out-LogFile ("Auditing not enabled for " + $User) -Information Out-LogFile "Enable auditing to track mailbox access patterns." -Information } Out-LogFile "Completed collection of Mailbox Audit Logs for $User from the UAL." -Information } } Function Get-HawkUserMailItemsAccessed { <# .SYNOPSIS This will export MailboxItemsAccessed operations from the Unified Audit Log (UAL). Must be connected to Exchange Online using the Connect-EXO or Connect-ExchangeOnline module. M365 E5 or G5 license is required for this function to work. This telemetry will ONLY be availabe if Advanced Auditing is enabled for the M365 user. .DESCRIPTION Recent attacker activities have illuminated the use of the Graph API to read user mailbox contents. This will export logs that will be present if the attacker is using the Graph API for such actions. Note: NOT all graph API actions against a mailbox are malicious. Review the results of this function and look for suspicious access of mailbox items associated with a specific user. .PARAMETER UserPrincipalName Specific user(s) to be investigated .EXAMPLE Get-HawkUserMailItemsAccessed -UserPrincipalName bsmith@contoso.com Gets MailItemsAccessed from Unified Audit Log (UAL) that corresponds to the User ID that is provided .OUTPUTS MailItemsAccessed_bsmith@contoso.com.csv /json Simple_MailItemsAccessed.csv/json .LINK https://www.microsoft.com/security/blog/2020/12/21/advice-for-incident-responders-on-recovery-from-systemic-identity-compromises/ .NOTES "Operation Properties" and "Folders" will return "System.Object" as they are nested JSON within the AuditData field. You will need to conduct individual log pull and review via PowerShell or other SIEM to determine values for those fields. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" } PROCESS { #Verify UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($UserObject in $UserArray) { [string]$User = $UserObject.UserPrincipalName Out-LogFile "Initiating collection of MailItemsAccessed for $User from the UAL." -Action Out-LogFile "Please be patient, this can take a while..." -Information # Verify that user has operation enabled for auditing. Otherwise, move onto next user. if (Test-OperationEnabled -User $User -Operation 'MailItemsAccessed') { Out-LogFile "Operation 'MailItemsAccessed' verified enabled for $User." -Information try { #Retrieve all audit data for mailitems accessed $SearchCommand = "Search-UnifiedAuditLog -Operations 'MailItemsAccessed' -UserIds $User" $MailboxItemsAccessed = Get-AllUnifiedAuditLogEntry -UnifiedSearch $SearchCommand if ($MailboxItemsAccessed.Count -gt 0) { #Define output directory path for user $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User #Create user directory if it doesn't already exist if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } #Compress raw data into more simple view $MailboxItemsAccessedSimple = $MailboxItemsAccessed | Get-SimpleUnifiedAuditLog #Export both raw and simplistic views to specified user's folder $MailboxItemsAccessed | Select-Object -ExpandProperty AuditData | Convertfrom-Json | Out-MultipleFileType -FilePrefix "MailItemsAccessed_$User" -User $User -csv -json $MailboxItemsAccessedSimple | Out-MultipleFileType -FilePrefix "Simple_MailItemsAccessed_$User" -User $User -csv -json } else { Out-LogFile "No MailItemsAccessed found for $User." -Information } } catch { Out-LogFile "Error processing mail items accessed for $User : $_" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } else { Out-LogFile "Operation 'MailItemsAccessed' is not enabled for $User." -Information Out-LogFile "No data recorded for $User." -Information } Out-LogFile "Completed collection of MailItemsAccessed for $User from the UAL." -Information } } END { } } Function Get-HawkUserMailSendActivity { <# .SYNOPSIS This will export Send operations from the Unified Audit Log (UAL). Must be connected to Exchange Online using the Connect-EXO or Connect-ExchangeOnline module. M365 E5 or G5 license is required for this function to work. This telemetry will ONLY be availabe if Advanced Auditing is enabled for the M365 user. .DESCRIPTION This function queries for message-sending activity within Exchange, providing visibility into outbound communications that could be relevant for identifying data exfiltration attempts, phishing campaigns, or other malicious activity. .PARAMETER UserPrincipalName Specific user(s) to be investigated .EXAMPLE Get-HawkUserMailSendActivity -UserPrincipalName bsmith@contoso.com Returns send activity queries from Unified Audit Log (UAL) that correspond to the UserPrincipalName that is provided .OUTPUTS SendActivity_bsmith@contoso.com.csv /json Simple_SendActivity_bsmith@contoso.com.csv/json .LINK https://www.microsoft.com/security/blog/2020/12/21/advice-for-incident-responders-on-recovery-from-systemic-identity-compromises/ .NOTES "Operation Properties" and "Folders" will return "System.Object" as they are nested JSON within the AuditData field. You will need to conduct individual log pull and review via PowerShell or other SIEM to determine values for those fields. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" }#End Begin PROCESS { #Verify UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($UserObject in $UserArray) { [string]$User = $UserObject.UserPrincipalName Out-LogFile "Initiating collection of mail 'Send' logs for $User from the UAL." -Action Out-LogFile "Please be patient, this can take a while..." -Information # Verify that user has operation enabled for auditing. Otherwise, move onto next user. if (Test-OperationEnabled -User $User -Operation 'Send') { Out-LogFile "Operation 'Send' verified enabled for $User." -Information try { #Retrieve all audit data for Exchange send activity $SearchCommand = "Search-UnifiedAuditLog -Operations 'Send' -UserIds $User" $ExchangeSends = Get-AllUnifiedAuditLogEntry -UnifiedSearch $SearchCommand if ($ExchangeSends.Count -gt 0) { #Define output directory path for user $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User #Create user directory if it doesn't already exist if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } #Compress raw data into more simple view $ExchangeSendsSimple = $ExchangeSends | Get-SimpleUnifiedAuditLog #Export both raw and simplistic views to specified user's folder $ExchangeSends | Select-Object -ExpandProperty AuditData | Convertfrom-Json | Out-MultipleFileType -FilePrefix "SendActivity_$User" -User $User -csv -json $ExchangeSendsSimple | Out-MultipleFileType -FilePrefix "Simple_SendActivity_$User" -User $User -csv -json } else { Out-LogFile "No mail 'Send' logs found for $User." -Information } } catch { Out-LogFile "Error processing Send Activity for $User : $_" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } else { Out-LogFile "Operation 'Send' is not enabled for $User." -Information Out-LogFile "No data recorded for $User." -Information } Out-LogFile "Completed collection of mail 'Send' logs for $User from the UAL." -Information } }#End Process END { }#End End } Function Get-HawkUserMessageTrace { <# .SYNOPSIS Pull that last 7 days of message trace data for the specified user. .DESCRIPTION Pulls the basic message trace data for the specified user. Can only pull the last 7 days as that is all we keep in get-messagetrace Further investigation will require Start-HistoricalSearch .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: Message_Trace.csv Path: \<User> Description: Output of Get-MessageTrace -Sender <primarysmtpaddress> .EXAMPLE Get-HawkUserMessageTrace -UserPrincipalName user@contoso.com Gets the message trace for user@contoso.com for the last 7 days #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName # Gather the trace foreach ($Object in $UserArray) { Out-LogFile "Initiating collection of Message Trace Data for $User from Exchange Online." -Action [string]$User = $Object.UserPrincipalName [string]$PrimarySMTP = (Get-Mailbox -identity $User).primarysmtpaddress if ([string]::IsNullOrEmpty($PrimarySMTP)) { Out-LogFile ("Failed to find Primary SMTP Address for user: " + $User) -isError Write-Error ("Failed to find Primary SMTP Address for user: " + $User) } else { # Get the 7 day message trace for the primary SMTP address as the sender Out-LogFile ("Gathering messages sent by: " + $PrimarySMTP) -action (Get-MessageTrace -Sender $PrimarySMTP) | Out-MultipleFileType -FilePreFix "Message_Trace" -user $User -csv -json } Out-LogFile "Completed collection of Message Trace Data for $User from Exchange Online." -Information } } Function Get-HawkUserMobileDevice { <# .SYNOPSIS Gathers mobile devices that are connected to the account .DESCRIPTION Pulls all mobile devices attached to them mailbox using get-mobiledevice If any devices had their first sync inside of the investigation window it will flag them. Investigator should follow up on these devices .PARAMETER UserPrincipalName Single UPN of a user, commans seperated list of UPNs, or array of objects that contain UPNs. .OUTPUTS File: MobileDevices.csv Path: \<User> Description: All mobile devices attached to the mailbox File: _Investigate_MobileDevice.csv Path: \<User> Descriptoin: Any devices that were found to have their first sync inside of the investigation window .EXAMPLE Get-HawkUserMessageTrace -UserPrincipalName user@contoso.com Gets the message trace for user@contoso.com for the last 7 days #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName # Gather the trace foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName Out-LogFile "Initiating collection of mobile devices attached to $User inbox from Exchange Online." -Action # Get all mobile devices Out-Logfile ("Gathering Mobile Devices for: " + $User) -Action [array]$MobileDevices = Get-MobileDevice -mailbox $User if ($Null -eq $MobileDevices) { Out-Logfile ("No mobile devices found for user: " + $User) -action } else { Out-Logfile ("Found " + $MobileDevices.count + " Devices") -Information # Check each device to see if it was NEW # If so flag it for investigation foreach ($Device in $MobileDevices) { if ($Device.FirstSyncTime -gt $Hawk.StartDate) { Out-Logfile ("Device found that was first synced inside investigation window") -notice Out-LogFile ("DeviceID: " + $Device.DeviceID) -notice $Device | Out-MultipleFileType -FilePreFix "_Investigate_MobileDevice" -user $user -csv -json -append -Notice } } # Output all devices found $MobileDevices | Out-MultipleFileType -FilePreFix "MobileDevices" -user $user -csv -json } Out-LogFile "Completed collection of mobile devices attached to $User inbox from Exchange Online." -Information } } Function Get-HawkUserPWNCheck { <# .SYNOPSIS Checks an email address against haveibeenpwned.com .DESCRIPTION Checks a single email address against HaveIBeenPwned. An API key is required and can be obtained from https://haveibeenpwned.com/API/Key for $3.50 a month. This script will prompt for the key if $hibpkey is not set as a variable. .PARAMETER EmailAddress Accepts since EMail address or array of Email address strings. DOES NOT Accept an array of objects (it will end up checked the UPN and not the email address) .OUTPUTS File: Have_I_Been_Pwned.txt Path: \<user> Description: Information returned from the pwned database .EXAMPLE Get-HawkUserPWNCheck -EmailAddress user@company.com Returns the pwn state of the email address provided #> param( [string[]]$EmailAddress ) BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject Send-AIEvent -Event "CmdRun" } if ($null -eq $hibpkey) { Write-Host -ForegroundColor Green " HaveIBeenPwned.com now requires an API access key to gather Stats with from their API. Please purchase an API key for `$3.95 a month from get a Free access key from https://haveibeenpwned.com/API/Key and provide it below. " # get the access key from the user Out-LogFile "haveibeenpwned.com apikey" -isPrompt -NoNewLine $hibpkey = (Read-Host).Trim() } }#End of BEGIN block # Verify our UPN input PROCESS { # Used to silence PSSA parameter usage warning if ($null -eq $EmailAddress) { return } [array]$UserArray = Test-UserObject -ToTest $EmailAddress $headers=@{'hibp-api-key' = $hibpkey} foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Convert the email to URL encoding $uriEncodeEmail = [uri]::EscapeDataString($($user)) # Build and invoke the URL $InvokeURL = 'https://haveibeenpwned.com/api/v3/breachedaccount/' + $uriEncodeEmail + '?truncateResponse=false' $Error.clear() #Will catch the error if the email is not found. 404 error means that the email is not found in the database. #https://haveibeenpwned.com/API/v3#ResponseCodes contains the response codes for the API try { $Result = Invoke-WebRequest -Uri $InvokeURL -Headers $headers -userAgent 'Hawk' -ErrorAction Stop } catch { $StatusCode = $_.Exception.Response.StatusCode $ErrorMessage = $_.Exception.Message switch ($StatusCode) { NotFound{ write-host "Email Provided Not Found in Pwned Database" return } Unauthorized{ write-host "Unauthorised Access - API key provided is not valid or has expired" return } Default { write-host $ErrorMessage return } } } # Convert the result into a PS custgom object $Pwned = $Result.content | ConvertFrom-Json # Output the value Out-LogFile ("Email Address found in " + $pwned.count) $Pwned | Out-MultipleFileType -FilePreFix "Have_I_Been_Pwned" -user $user -txt } }#End of PROCESS block END { Start-Sleep -Milliseconds 1500 }#End of END block }#End of Function Get-HawkUserPWNCheck Function Get-HawkUserSharePointSearchQuery { <# .SYNOPSIS This will export SearchQueryInitiatedSharePoint operations from the Unified Audit Log (UAL). Must be connected to Exchange Online using the Connect-EXO or Connect-ExchangeOnline module. M365 E5 or G5 license is required for this function to work. This telemetry will ONLY be availabe if Advanced Auditing is enabled for the M365 user. .DESCRIPTION This function tracks searches performed in SharePoint, providing visibility into user search behavior across sensitive documents and sites. .PARAMETER UserPrincipalName Specific user(s) to be investigated .EXAMPLE Get-HawkUserSharePointSearchQuery -UserPrincipalName bsmith@contoso.com Returns send activity queries from Unified Audit Log (UAL) that correspond to the UserPrincipalName that is provided .OUTPUTS SharePointSearches_bsmith@contoso.com.csv /json Simple_SharePointSearches_bsmith@contoso.com.csv/json .LINK https://www.microsoft.com/security/blog/2020/12/21/advice-for-incident-responders-on-recovery-from-systemic-identity-compromises/ .NOTES "Operation Properties" and "Folders" will return "System.Object" as they are nested JSON within the AuditData field. You will need to conduct individual log pull and review via PowerShell or other SIEM to determine values for those fields. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$UserPrincipalName ) BEGIN { # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" }#End Begin PROCESS { #Verify UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($UserObject in $UserArray) { [string]$User = $UserObject.UserPrincipalName Out-LogFile "Initiating collection of SharePoint Search queries for $User from the UAL." -Action Out-LogFile "Please be patient, this can take a while..." -Information # Verify that user has operation enabled for auditing. Otherwise, move onto next user. if (Test-OperationEnabled -User $User -Operation 'SearchQueryInitiated') { Out-LogFile "Operation 'SearchQueryInitiated' verified enabled for $User." -Information try { #Retrieve all audit data for SharePoint search activity $SearchCommand = "Search-UnifiedAuditLog -Operations 'SearchQueryInitiatedSharePoint' -UserIds $User" $SharePointSearches = Get-AllUnifiedAuditLogEntry -UnifiedSearch $SearchCommand if ($SharePointSearches.Count -gt 0) { #Define output directory path for user $UserFolder = Join-Path -Path $Hawk.FilePath -ChildPath $User #Create user directory if it doesn't already exist if (-not (Test-Path -Path $UserFolder)) { New-Item -Path $UserFolder -ItemType Directory -Force | Out-Null } #Compress raw data into more simple view $SharePointSearchesSimple = $SharePointSearches | Get-SimpleUnifiedAuditLog #Export both raw and simplistic views to specified user's folder $SharePointSearches | Select-Object -ExpandProperty AuditData | Convertfrom-Json | Out-MultipleFileType -FilePrefix "SharePointSearches_$User" -User $User -csv -json $SharePointSearchesSimple | Out-MultipleFileType -FilePrefix "Simple_SharePointSearches_$User" -User $User -csv -json } else { Out-LogFile "No SharePoint Search Queries found for $User." -Information } } catch { Out-LogFile "Error processing SharePoint Search Activity for $User : $_" -isError Write-Error -ErrorRecord $_ -ErrorAction Continue } } else { Out-LogFile "Operation 'SearchQueryInitiated' is not enabled for $User." -Information Out-LogFile "No data recorded for $User." -Information } Out-LogFile "Completed collection of SharePoint Search queries for $User from the UAL." -Information } }#End Process END { }#End End } Function Get-HawkUserUALSignInLog { <# .SYNOPSIS Gathers ip addresses that logged into the user account .DESCRIPTION Pulls AzureActiveDirectoryAccountLogon events from the unified audit log for the provided user. If used with -ResolveIPLocations: Attempts to resolve the IP location using freegeoip.net Will flag ip addresses that are known to be owned by Microsoft using the XML from: https://support.office.com/en-us/article/URLs-and-IP-address-ranges-for-Office-365-operated-by-21Vianet-5C47C07D-F9B6-4B78-A329-BFDC1B6DA7A0 .PARAMETER UserPrincipalName Single UPN of a user, comma seperated list of UPNs, or array of objects that contain UPNs. .PARAMETER ResolveIPLocations Resolved IP Locations .OUTPUTS File: Converted_Authentication_Logs.csv Path: \<User> Description: All authentication activity for the user in a more readable form .EXAMPLE Get-HawkUserUALSignInLog -UserPrincipalName user@contoso.com -ResolveIPLocations Gathers authentication information for user@contoso.com. Attempts to resolve the IP locations for all authentication IPs found. .EXAMPLE Get-HawkUserUALSignInLog -UserPrincipalName (get-mailbox -Filter {Customattribute1 -eq "C-level"}) -ResolveIPLocations Gathers authentication information for all users that have "C-Level" set in CustomAttribute1 Attempts to resolve the IP locations for all authentication IPs found. #> param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName, [switch]$ResolveIPLocations ) # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } Test-EXOConnection Send-AIEvent -Event "CmdRun" # Verify our UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName [array]$RecordTypes = "AzureActiveDirectoryAccountLogon", "AzureActiveDirectory", "AzureActiveDirectoryStsLogon" foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName # Make sure our array is null [array]$UserLogonLogs = $null Out-LogFile "Initiating collection of Sign-In logs for $User from the UAL." -Action # Get back the account logon logs for the user foreach ($Type in $RecordTypes) { Out-LogFile ("Searching Unified Audit log for Records of type: " + $Type) -action $UserLogonLogs += Get-AllUnifiedAuditLogEntry -UnifiedSearch ("Search-UnifiedAuditLog -UserIds " + $User + " -RecordType " + $Type) } # Make sure we have results if ($null -eq $UserLogonLogs) { Out-LogFile "No results found when searching UAL for AzureActiveDirectoryAccountLogon events" -isError } else { # Expand out the AuditData and convert from JSON Out-LogFile "Converting AuditData" -action $ExpandedUserLogonLogs = $null $ExpandedUserLogonLogs = New-Object System.Collections.ArrayList $FailedConversions = $null $FailedConversions = New-Object System.Collections.ArrayList # Process our results in a way to deal with JSON Errors Foreach ($Entry in $UserLogonLogs) { try { $jsonEntry = $Entry.AuditData | ConvertFrom-Json $ExpandedUserLogonLogs.Add($jsonEntry) | Out-Null } catch { $FailedConversions.Add($Entry) | Out-Null } } if ($FailedConversions.Count -le 0) { # Do nothing or handle the zero-case } else { Out-LogFile ("$($FailedConversions.Count) Entries failed JSON Conversion") -isError $FailedConversions | Out-MultipleFileType -FilePrefix "Failed_Conversion_Authentication_Logs" -User $User -Csv -Json } # Add IP Geo Location information to the data if ($ResolveIPLocations) { Out-File "Resolving IP Locations" # Setup our counter $i = 0 # Loop thru each connection and get the location while ($i -lt $ExpandedUserLogonLogs.Count) { if ([bool]($i % 25)) { } Else { Write-Progress -Activity "Looking Up Ip Address Locations" -CurrentOperation $i -PercentComplete (($i / $ExpandedUserLogonLogs.count) * 100) } # Get the location information for this IP address if ($ExpandedUserLogonLogs.item($i).clientip) { $Location = Get-IPGeolocation -ipaddress $ExpandedUserLogonLogs.item($i).clientip } else { $Location = "IP Address Null" } # Combine the connection object and the location object so that we have a single output ready $ExpandedUserLogonLogs.item($i) = ($ExpandedUserLogonLogs.item($i) | Select-Object -Property *, @{Name = "CountryName"; Expression = { $Location.CountryName } }, @{Name = "RegionCode"; Expression = { $Location.RegionCode } }, @{Name = "RegionName"; Expression = { $Location.RegionName } }, @{Name = "City"; Expression = { $Location.City } }, @{Name = "KnownMicrosoftIP"; Expression = { $Location.KnownMicrosoftIP } }) # increment our counter for the progress bar $i++ } Write-Progress -Completed -Activity "Looking Up Ip Address Locations" -Status " " } else { Out-LogFile "ResolveIPLocations not specified" -Information } # Convert to human readable and export Out-LogFile "Converting to Human Readable" -action (Import-AzureAuthenticationLog -JsonConvertedLogs $ExpandedUserLogonLogs) | Out-MultipleFileType -fileprefix "Converted_Authentication_Logs" -User $User -csv -json # Export RAW data $UserLogonLogs | Out-MultipleFileType -fileprefix "Raw_Authentication_Logs" -user $User -csv -json } Out-LogFile "Completed collection of Sign-In logs for $User from the UAL." -Information } } Function Start-HawkUserInvestigation { <# .SYNOPSIS Performs a comprehensive user-specific investigation using Hawk's automated data collection capabilities. .DESCRIPTION Start-HawkUserInvestigation automates the collection and analysis of Microsoft 365 security data for specific users. It runs multiple specialized cmdlets to gather detailed information about user configuration, activities, and potential security concerns. The command can run in either interactive mode (default) or non-interactive mode. Interactive mode is used when only UserPrincipalName is provided, while non-interactive mode is automatically enabled when any additional parameter is specified. Data collected includes: - User mailbox configuration and statistics - Inbox rules and email forwarding settings - Authentication history and mailbox audit logs - Administrative changes affecting the user - Message trace data and mobile device access - AutoReply configuration All collected data is stored in a structured format for analysis, with suspicious findings highlighted for investigation. .PARAMETER UserPrincipalName Single UPN of a user, comma-separated list of UPNs, or an array of objects that contain UPNs. This is the only required parameter and specifies which users to investigate. .PARAMETER StartDate The beginning date for the investigation period. When specified, must be used with EndDate. Cannot be later than EndDate and the date range cannot exceed 365 days. Providing this parameter automatically enables non-interactive mode. Format: MM/DD/YYYY .PARAMETER EndDate The ending date for the investigation period. When specified, must be used with StartDate. Cannot be in the future and the date range cannot exceed 365 days. Providing this parameter automatically enables non-interactive mode. Format: MM/DD/YYYY .PARAMETER DaysToLookBack Alternative to StartDate/EndDate. Specifies the number of days to look back from the current date. Must be between 1 and 365. Cannot be used together with StartDate. Providing this parameter automatically enables non-interactive mode. .PARAMETER FilePath The file system path where investigation results will be stored. Required in non-interactive mode. Must be a valid file system path. Providing this parameter automatically enables non-interactive mode. .PARAMETER SkipUpdate Switch to bypass the automatic check for Hawk module updates. Useful in automated scenarios or air-gapped environments. Providing this parameter automatically enables non-interactive mode. .PARAMETER Confirm Prompts you for confirmation before executing each investigation step. By default, confirmation prompts appear for operations that could collect sensitive data. .PARAMETER WhatIf Shows what would happen if the command runs. The command is not executed. Use this parameter to understand which investigation steps would be performed without actually collecting data. .OUTPUTS Creates multiple CSV and JSON files containing investigation results. All outputs are organized in user-specific folders under the specified FilePath directory. See individual cmdlet help for specific output details. .EXAMPLE Start-HawkUserInvestigation -UserPrincipalName user@contoso.com Investigates a single user in interactive mode, prompting for date range and output location. .EXAMPLE Start-HawkUserInvestigation -UserPrincipalName user@contoso.com -DaysToLookBack 30 -FilePath "C:\Investigation" Investigates a single user looking back 30 days, saving results to C:\Investigation. Runs in non-interactive mode because parameters beyond UserPrincipalName were specified. .EXAMPLE Start-HawkUserInvestigation ` -UserPrincipalName (Get-Mailbox -Filter {CustomAttribute1 -eq "C-level"}) ` -StartDate "01/01/2024" ` -EndDate "01/31/2024" ` -FilePath "C:\Investigation" Investigates all users with CustomAttribute1="C-level" for January 2024. Runs in non-interactive mode because multiple parameters were specified. .LINK https://hawkforensics.io .LINK https://github.com/T0pCyber/hawk #> [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName, [DateTime]$StartDate, [DateTime]$EndDate, [int]$DaysToLookBack, [string]$FilePath, [switch]$SkipUpdate ) begin { $NonInteractive = Test-HawkNonInteractiveMode -PSBoundParameters $PSBoundParameters Send-AIEvent -Event "CmdRun" if ($NonInteractive) { $processedDates = Test-HawkDateParameter -PSBoundParameters $PSBoundParameters -StartDate $StartDate -EndDate $EndDate -DaysToLookBack $DaysToLookBack $StartDate = $processedDates.StartDate $EndDate = $processedDates.EndDate # Now call validation with updated StartDate/EndDate $validation = Test-HawkInvestigationParameter ` -StartDate $StartDate -EndDate $EndDate ` -DaysToLookBack $DaysToLookBack -FilePath $FilePath -NonInteractive if (-not $validation.IsValid) { foreach ($error in $validation.ErrorMessages) { Stop-PSFFunction -Message $error -EnableException $true } } try { Initialize-HawkGlobalObject -StartDate $StartDate -EndDate $EndDate ` -DaysToLookBack $DaysToLookBack -FilePath $FilePath ` -SkipUpdate:$SkipUpdate -NonInteractive:$NonInteractive } catch { Stop-PSFFunction -Message "Failed to initialize Hawk: $_" -EnableException $true } } } process { if (Test-PSFFunctionInterrupt) { return } # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { Initialize-HawkGlobalObject } $investigationStartTime = Get-Date if ($PSCmdlet.ShouldProcess("Investigating Users")) { Out-LogFile "Starting User Investigation." -Action Send-AIEvent -Event "CmdRun" # Verify the UPN input [array]$UserArray = Test-UserObject -ToTest $UserPrincipalName foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName if ($PSCmdlet.ShouldProcess("Running Get-HawkUserConfiguration for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserConfiguration." -Action Get-HawkUserConfiguration -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserInboxRule for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserInboxRule." -Action Get-HawkUserInboxRule -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserEmailForwarding for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserEmailForwarding." -Action Get-HawkUserEmailForwarding -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserAutoReply for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserAutoReply." -Action Get-HawkUserAutoReply -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserEntraIDSignInLog for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserEntraIDSignInLog." -Action Get-HawkUserEntraIDSignInLog -UserPrincipalName $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserUALSignInLog for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserUALSignInLog." -Action Get-HawkUserUALSignInLog -User $User -ResolveIPLocations } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMailboxAuditing for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserMailboxAuditing." -Action Get-HawkUserMailboxAuditing -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserAdminAudit for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserAdminAudit." -Action Get-HawkUserAdminAudit -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMessageTrace for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserMessageTrace." -Action Get-HawkUserMessageTrace -User $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMailItemsAccessed for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserMailItemsAccessed." -Action Get-HawkUserMailItemsAccessed -UserPrincipalName $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserExchangeSearchQuery for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserExchangeSearchQuery." -Action Get-HawkUserExchangeSearchQuery -UserPrincipalName $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMailSendActivity for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserMailSendActivity." -Action Get-HawkUserMailSendActivity -UserPrincipalName $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserSharePointSearchQuery for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserSharePointSearchQuery." -Action Get-HawkUserSharePointSearchQuery -UserPrincipalName $User } if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMobileDevice for $User")) { Write-Output "" Out-LogFile "Running Get-HawkUserMobileDevice." -Action Get-HawkUserMobileDevice -User $User } } } } end { # Calculate end time and display summary $investigationEndTime = Get-Date Write-HawkInvestigationSummary -StartTime $investigationStartTime -EndTime $investigationEndTime -InvestigationType 'User' -UserPrincipalName $UserPrincipalName } } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'Hawk' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'Hawk' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'Hawk' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." #Set-PSFConfig -Module 'Hawk' -Name 'DaysToLookBack' -Value 90 -Initialize -Validation integerpositive -Description 'How long into the past will the project look' $handler = { $paramSetPSFLoggingProvider = @{ Name = 'logfile' InstanceName = 'Hawk' FilePath = Join-Path -path $args[0] -ChildPath '%date%_logs.csv' TimeFormat = 'yyyy-MM-dd HH:mm:ss.fff' IncludeModules = 'Hawk' UTC = $true Enabled = $true } Set-PSFLoggingProvider @paramSetPSFLoggingProvider } Set-PSFConfig -Module 'Hawk' -Name "FilePath" -Value '' -Initialize -Validation string -Handler $handler -Description 'Path where the module maintains logs and exports data' <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'Hawk.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "Hawk.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name Hawk.alcohol #> New-PSFLicense -Product 'Hawk' -Manufacturer 'Paul Navarro' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2020-11-25") -Text @" Copyright (c) 2023 Paul Navarro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ #endregion Load compiled code |