RedKite.psm1
function Write-Log { param ( [string]$Message, [string]$LogFile, [ValidateSet("INFO", "WARN", "ALERT", "ERROR")] [string]$Level = "INFO" ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logEntry = "$timestamp [$Level] $Message" Add-Content -Path $LogFile -Value $logEntry Write-Host $logEntry } function Test-RequiredModules { param ( [string[]]$Modules ) foreach ($module in $Modules) { Write-Host "Checking module '$module'..." $installed = Get-Module -ListAvailable -Name $module if (-not $installed) { Write-Host "Module '$module' is NOT installed." -ForegroundColor Yellow # Prompt user for installation $install = Read-Host "Module '$module' is required. Would you like to install it now? (Y/N)" if ($install -match '^[Yy]') { try { # Check if NuGet provider is available if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Write-Host "NuGet provider not found. Installing NuGet provider..." Install-PackageProvider -Name NuGet -Force -Scope CurrentUser } # Install module Write-Host "Installing module '$module'..." Install-Module -Name $module -Scope CurrentUser -Force -AllowClobber Write-Host "Module '$module' installed successfully." -ForegroundColor Green } catch { Write-Error "Failed to install module '$module': $($_.Exception.Message)" return } } else { Write-Host "Please install the module manually using 'Install-Module $module -Scope CurrentUser'." -ForegroundColor Red return } } else { Write-Host "Module '$module' is installed." -ForegroundColor Green $imported = Get-Module -Name $module if (-not $imported) { Write-Host "Importing module '$module'..." Import-Module $module -Force Write-Host "Module '$module' imported successfully." -ForegroundColor Green } else { Write-Host "Module '$module' is already imported." -ForegroundColor Cyan } } } Write-Host "All module requirements met." -ForegroundColor Green Write-Host "" Write-Host "Type 'Start-Redkite' when ready." -ForegroundColor Yellow } # Run this check before starting the main script Test-RequiredModules -Modules @('Microsoft.Graph.Users', 'ExchangeOnlineManagement') function Start-Redkite { [CmdletBinding()] param () Write-Host @" # ###### ## #### ######## ## ############ ####### #### ================================================ ### ########### Welcome to RedKite Phishing Indicators Checker ### ######## ================================================ ###### ####### ###### ######### # ## ########## ################# ################ ## ############## #### ##### ##### ###### ######### ########### ###### ################ ### ####################### ########################### #### ############################ #################### ## ############## ############# ############ #### ##### ######## ##### # ############# # #### ## ####### # ### ######### ## # #### ### ##### ## ## # ##### # #### # ##### # #### ## #### # #### # ## ## ## ## ## # # "@ -ForegroundColor Red Start-Sleep -Seconds 3 # Pause for 2 seconds Write-Host " " Write-Host "This tool is designed to check ExchangeOnline for common indicators of compromised accounts." Write-Host "The checks focus on email phishing attacks; looking at commonly used inbox rules and external re-directs." Write-Host " " Write-Host "===== RedKite should be used as part of a full investigation =====" -ForegroundColor Yellow Write-Host " " Write-Host "The checks covered in this version include;" Write-Host " -External redirects" Write-Host " -Mailbox rules (Delete email, Move to folder and mark as read)" Write-Host " -Recent mailbox changes (optional)" Write-Host " " Write-Host "Please choose from the following options and then connect to MgGraph/ExchangeOnline with admin privileges when prompted" -ForegroundColor Yellow # Select users or all users $choice = Read-Host "Would you like to check (A)ll users or (S)pecific users? [A/S]" $users = @() if ($choice -match '^[Ss]') { do { $user = Read-Host "Enter user principal name (email) or press Enter to finish" if ($user) { $users += $user } } while ($user) } else { Write-Host "Option 'Check all users from Azure AD' selected" -ForegroundColor Cyan Write-Host "Connecting to Microsoft Graph" -ForegroundColor Cyan try { Connect-Graph -Scopes "User.Read.All" -NoWelcome $users = Get-MgUser -All -Property UserPrincipalName | Select-Object -ExpandProperty UserPrincipalName } catch { Write-Error "Failed to get users from Azure AD: $($_.Exception.Message)" return } } # Prompt for log folder $defaultFolder = "$HOME\Documents\RedkiteLogs" $logFolder = Read-Host "Enter folder path for logs or press Enter to use default [$defaultFolder]" if ([string]::IsNullOrWhiteSpace($logFolder)) { $logFolder = $defaultFolder } if (-not (Test-Path $logFolder)) { try { New-Item -ItemType Directory -Path $logFolder -Force | Out-Null } catch { Write-Error "Failed to create log folder: $($_.Exception.Message)" return } } $logFile = Join-Path -Path $logFolder -ChildPath "Redkite_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" Write-Host "Logs will be saved to $logFile" -ForegroundColor Cyan # Prompt for lookback days $lookbackDaysInput = Read-Host "Enter how many days back to check (default is 90)" if (-not [int]::TryParse($lookbackDaysInput, [ref]$null) -or [int]$lookbackDaysInput -le 0) { $lookbackDays = 90 } else { $lookbackDays = [int]$lookbackDaysInput } Write-Host "Lookback period set to $lookbackDays days." -ForegroundColor Cyan function Get-M365PhishIndicators { param( [string[]]$usersChecked, [string]$LogFile, [int]$LookbackDays ) $results = @() try { Write-Host "[1/4] Connecting to Exchange Online..." -ForegroundColor Cyan Connect-ExchangeOnline -ShowProgress $false -ErrorAction Stop # Retrieve accepted domains Write-Host "Retrieving accepted domains from Exchange Online..." -ForegroundColor Cyan try { $acceptedDomains = Get-AcceptedDomain | Select-Object -ExpandProperty DomainName Write-Host "Detected accepted domains: $($acceptedDomains -join ', ')" -ForegroundColor Green } catch { Write-Warning "Unable to retrieve accepted domains from Exchange Online. Please check your permissions." $acceptedDomains = @() } } catch { $errorMsg = "Error connecting to Exchange Online: $($_.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Exchange Online Connection" Detail = $errorMsg Status = "ERROR" } return $results } # Inbox Rules Check Write-Host "[2/4] Checking inbox rules (Exchange Online)..." -ForegroundColor Cyan try { $totalUsers = $usersChecked.Count for ($i = 0; $i -lt $totalUsers; $i++) { $user = $usersChecked[$i] # Update progress bar $percentComplete = [int](($i / $totalUsers) * 100) Write-Progress -Activity "Checking inbox rules" -Status "Processing user $($user) ($($i+1)/$totalUsers)" -PercentComplete $percentComplete try { $rules = Get-InboxRule -Mailbox $user -ErrorAction Stop foreach ($rule in $rules) { # Check for suspicious actions if ( ($rule.DeleteMessage) -or ($rule.MarkAsRead) -or ($rule.MoveToFolder -and $rule.MarkAsRead) ) { $entry = [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ALERT" Check = "Inbox Rules" Detail = "User: $user - Rule: $($rule.Name) - Action: " + "$(if ($rule.DeleteMessage) {'DeleteMessage '})" + "$(if ($rule.MarkAsRead) {'MarkAsRead '})" + "$(if ($rule.MoveToFolder) {'MoveToFolder: ' + $rule.MoveToFolder.FolderPath})" Status = "Suspicious mailbox rules - Investigation advised" } $results += $entry Write-Log $entry.Detail $LogFile "ALERT" } # Check for external forwarding in inbox rules if ($rule.ForwardTo -and $rule.ForwardTo.Count -gt 0) { foreach ($recipient in $rule.ForwardTo) { $recipientAddress = $recipient.ToString() $recipientDomain = ($recipientAddress -split "@")[-1].ToLower() if (-not ($acceptedDomains -contains $recipientDomain)) { $entry = [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ALERT" Check = "Inbox Rules" Detail = "User: $user - Rule: $($rule.Name) - Action: Forward to external address: $recipientAddress" Status = "External forward in place" } $results += $entry Write-Log $entry.Detail $LogFile "ALERT" } } } } } catch { $errorMessage = $_.Exception.Message if ($errorMessage -match "(?i)couldn't be found|RecipientNotFound|doesn't exist|does not exist|cannot be found") { if ($errorMessage -match "(?i)\balias\b|\bis an alias\b|\balternate address\b|\bmail-enabled contact\b|\bforwarding\b") { $errorMsg = "Mailbox is an alias or forwarding address for user ${user}. Skipping..." } else { $errorMsg = "Mailbox is an alias or does not exist for user ${user}. Skipping..." } Write-Log $errorMsg $LogFile "WARN" } else { $errorMsg = "Error checking inbox rules for ${user}: $($_.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Inbox Rules" Detail = $errorMsg Status = "ERROR" } } } } # Clear inbox rules progress bar Write-Progress -Activity "Checking inbox rules" -Completed } catch { $errorMsg = "Error during inbox rules check: $($_.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Inbox Rules" Detail = $errorMsg Status = "ERROR" } } # --- Now, check Shared Mailboxes separately, AFTER processing all user inbox rules --- Write-Host "[3/4] Checking shared mailboxes for suspicious settings..." -ForegroundColor Cyan try { $sharedMailboxes = Get-Mailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited $totalShared = $sharedMailboxes.Count for ($k = 0; $k -lt $totalShared; $k++) { $sharedMbx = $sharedMailboxes[$k] # Update progress bar $percentComplete = [int](($k / $totalShared) * 100) Write-Progress -Activity "Checking shared mailboxes" -Status "Processing shared mailbox $($sharedMbx.UserPrincipalName) ($($k+1)/$totalShared)" -PercentComplete $percentComplete try { # Check mailbox forwarding settings $mbxDetails = Get-Mailbox -Identity $sharedMbx.UserPrincipalName -ErrorAction Stop if ($mbxDetails.ForwardingSMTPAddress) { $forwardAddress = $mbxDetails.ForwardingSMTPAddress.ToString() $forwardDomain = ($forwardAddress -split "@")[-1].ToLower() if (-not ($acceptedDomains -contains $forwardDomain)) { $entry = [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ALERT" Check = "Shared Mailboxes" Detail = "Shared mailbox: $($sharedMbx.UserPrincipalName) forwards mail externally to $forwardAddress" Status = "External forwarding on shared mailbox" } $results += $entry Write-Log $entry.Detail $LogFile "ALERT" } } # Check inbox rules on shared mailboxes: $rules = Get-InboxRule -Mailbox $sharedMbx.UserPrincipalName -ErrorAction SilentlyContinue foreach ($rule in $rules) { if ( ($rule.DeleteMessage) -or ($rule.MarkAsRead) -or ($rule.MoveToFolder -and $rule.MarkAsRead) ) { $entry = [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ALERT" Check = "Shared Mailboxes - Inbox Rules" Detail = "Shared mailbox: $($sharedMbx.UserPrincipalName) - Rule: $($rule.Name) - Action: " + "$(if ($rule.DeleteMessage) {'DeleteMessage '})" + "$(if ($rule.MarkAsRead) {'MarkAsRead '})" + "$(if ($rule.MoveToFolder) {'MoveToFolder: ' + $rule.MoveToFolder.FolderPath})" Status = "Suspicious mailbox rules on shared mailbox" } $results += $entry Write-Log $entry.Detail $LogFile "ALERT" } } } catch { $errorMsg = "Error checking shared mailbox $($sharedMbx.UserPrincipalName): $($_.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Shared Mailboxes" Detail = $errorMsg Status = "ERROR" } } } # Clear progress bar Write-Progress -Activity "Checking inbox rules" -Completed } catch { $errorMsg = "Error during inbox rules check: $($PSItem.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Inbox Rules" Detail = $errorMsg Status = "ERROR" } } # Prompt user to continue Write-Host $continue = Read-Host "Do you want to continue with checking for recent mailbox changes? (Y/N)" if ($continue -ne "Y" -and $continue -ne "y") { Write-Host "Skipping recent mailbox changes. Outputting inbox rules results only." -ForegroundColor Yellow return $results } # Recent Mailbox Changes try { Write-Host "[4/4] Checking recent mailbox changes (Exchange Online)..." -ForegroundColor Cyan $mailboxes = Get-Mailbox -ResultSize Unlimited $totalMailboxes = $mailboxes.Count for ($j = 0; $j -lt $totalMailboxes; $j++) { $mbx = $mailboxes[$j] # Update progress bar $percentComplete = [int](($j / $totalMailboxes) * 100) Write-Progress -Activity "Checking recent mailbox changes" -Status "Processing mailbox $($mbx.UserPrincipalName) ($($j+1)/$totalMailboxes)" -PercentComplete $percentComplete try { $auditLogs = Search-MailboxAuditLog -Identity $mbx.UserPrincipalName ` -LogonTypes Owner,Delegate,Admin -ShowDetails ` -StartDate (Get-Date).AddDays(-$LookbackDays) ` -ErrorAction Stop foreach ($log in $auditLogs) { if ($log.Operation -eq "UpdateInboxRules" -or $log.Operation -eq "Set-Mailbox") { $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ALERT" Check = "Recent Mailbox Changes" Detail = "User: $($mbx.UserPrincipalName) - Action: $($log.Operation)" Status = "ALERT" } Write-Log "Recent Mailbox Change detected: User: $($mbx.UserPrincipalName) - Action: $($log.Operation)" $LogFile "ALERT" } } } catch { if ($errorMessage -match "(?i)couldn't be found|RecipientNotFound|doesn't exist|does not exist|cannot be found") { if ($errorMessage -match "(?i)\balias\b|\bis an alias\b|\balternate address\b|\bmail-enabled contact\b|\bforwarding\b") { $errorMsg = "Mailbox is an alias or forwarding address for user ${user}. Skipping..." } else { $errorMsg = "Mailbox is an alias or does not exist for user ${user}. Skipping..." } Write-Log $errorMsg $LogFile "WARN" } $errorMsg = "Error retrieving mailbox changes for user $($mbx.UserPrincipalName): $($_.Exception.Message)" Write-Log $errorMsg $LogFile "ERROR" $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Recent Mailbox Changes" Detail = $errorMsg Status = "ERROR" } } } # Clear progress bar Write-Progress -Activity "Checking recent mailbox changes" -Completed } catch { $results += [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "ERROR" Check = "Recent Mailbox Changes" Detail = "Error: $_" Status = "ERROR" } } # Summary entry if no alerts are found at all across any mailboxes if (-not ($results | Where-Object { $_.Status -eq "ALERT" })) { $entry = [PSCustomObject]@{ Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Level = "INFO" Check = "Summary" Detail = "RedKite checks complete." Status = "OK" } $results += $entry Write-Log $entry.Detail $LogFile "INFO" } return $results } # Run main checks $results = Get-M365PhishIndicators -usersChecked $users -LogFile $logFile -LookbackDays $lookbackDays Write-Host "Redkite checks completed." -ForegroundColor Cyan # Export results option $exportChoice = Read-Host "Would you like to export the results to a CSV file? (Y/N)" if ($exportChoice -match '^[Yy]') { $defaultExportFolder = "$HOME\Documents\RedkiteResults" $exportFolder = Read-Host "Enter folder path to save CSV or press Enter to use default [$defaultExportFolder]" if ([string]::IsNullOrWhiteSpace($exportFolder)) { $exportFolder = $defaultExportFolder } if (-not (Test-Path $exportFolder)) { try { New-Item -ItemType Directory -Path $exportFolder -Force | Out-Null } catch { Write-Error "Failed to create export folder: $($_.Exception.Message)" return } } $csvFile = Join-Path -Path $exportFolder -ChildPath "Redkite_Results_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" try { $results | Export-Csv -Path $csvFile -NoTypeInformation -Force Write-Host "Results exported to $csvFile" } catch { Write-Error "Failed to export CSV: $($_.Exception.Message)" } } # Prompt to disconnect Write-Host $disconnect = Read-Host "Do you want to disconnect from Exchange Online and Microsoft Graph? (Y/N)" if ($disconnect -eq "Y" -or $disconnect -eq "y") { try { Write-Host "Disconnecting from Exchange Online..." -ForegroundColor Cyan Disconnect-ExchangeOnline -Confirm:$false Write-Host "Disconnecting from Microsoft Graph..." -ForegroundColor Cyan Disconnect-MgGraph -ErrorAction SilentlyContinue } catch { Write-Warning "Error disconnecting: $($_.Exception.Message)" } } } Export-ModuleMember -Function Write-Log, Test-RequiredModules, Start-Redkite, Clear-GraphTokenCache, Get-M365PhishIndicators # SIG # Begin signature block # MIIFVQYJKoZIhvcNAQcCoIIFRjCCBUICAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB # gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR # AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUU2K6NCah3ldafs0JToJkjAs6 # b9OgggL4MIIC9DCCAdygAwIBAgIQY+vPo58WzL1B8Von0QzZrjANBgkqhkiG9w0B # AQsFADASMRAwDgYDVQQDDAdSZWRLaXRlMB4XDTI1MDYwMzE4NDg0NloXDTI2MDYw # MzE5MDg0NlowEjEQMA4GA1UEAwwHUmVkS2l0ZTCCASIwDQYJKoZIhvcNAQEBBQAD # ggEPADCCAQoCggEBALFokoxPL60YLnNP3LuIM9j+XEux+RMhis2LmfgiTsM7XixC # brR3plmrNNcDrBZv5GySvcdUhz+/1ARaUQgJWZwntG5jpDG1DMk67nCLjtaXhmhJ # kQzVUSplEa6yjutCMYqGjLyt8tpwq6dqgEdskDYzURm8Z/7gxnnw1qHAuKSwOH9z # bfMJgatlktkBMd+YwKGhOHjF3qQaVf6VGUXOrMUP+97XjfmeYOkqewU3IZ2cLqxk # jwGdAJ9td14DZl45kqVqupMwa9jaC25LzoVp9vXQGkiOQs12pPT5uWtRO2K/x2GD # IQL0dEMwJXFJM3QOrjQp+gWGtadFBVvuIEaG910CAwEAAaNGMEQwDgYDVR0PAQH/ # BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBRK3QorQIBVPxl2 # E5NSs/r5zZHq7jANBgkqhkiG9w0BAQsFAAOCAQEAhxAp9FWqs5UYKmDAh0Blob3T # Ug3dYRjaLJuX9o9XglSNYCTHVkRKjVTmChVzc4cYw9Vqytw9wP1ZO/xTiRg7eGtY # 05rrfkxxE++9+Y+0NOCBuiPrJ3UKME+gb6tCSmakjK5Q/f3DJ8RuLyETCi1EL/ui # GrEZltVkcTl4ENYYdBVCKMC3RWQL/RdEXVDxzM/qb2gdUHEwMHOtLEqrJELcFqgZ # h0EjbYYrcq61u1tYiNWvyHieEHmJpgDfIxkAhWrEDYSviyyu+UGUvmsgM8BoafCK # 99iSNGPN9QibQRNtZwCAXfBqBzFr/enT/JiP8puy3E4ZHTAx6+6/t1FOoxF5aDGC # AccwggHDAgEBMCYwEjEQMA4GA1UEAwwHUmVkS2l0ZQIQY+vPo58WzL1B8Von0QzZ # rjAJBgUrDgMCGgUAoHgwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZBgkqhkiG # 9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIB # FTAjBgkqhkiG9w0BCQQxFgQUYMjfzcvAuTXOZ/AepKqmlf9F/okwDQYJKoZIhvcN # AQEBBQAEggEAHpsXET8VcZ1hyzyNLq3ivJhUm6KuCASu8fXf8uQybVsESE5Ocnu3 # TdECFwWms4tX7AmSOm9zTgJwZTVPnyPIA1an2U9KcWyTnvlE7GpvZYzjJ/+ltdLs # 7NLnEE7FS8NnlVQ7TdonO9WikDj+4E2At1LVuUb5sLji5fzy5nJf7seLcykQkK3q # 0abT517cZuVDp6xLITHhx/4/D/f1aED4646AFLEz9IIbKePh7onsgDd0j3Jg+1tp # wX3QeEKyGFSJtUm1gc3v/7aPw6+gVkxJMXXXxyREcIp9Ku5UP3MnoFrDvzbq5NL5 # 8IXwurlF6iWzMCR2ziyKHvh0Wi+C/4mwaw== # SIG # End signature block |