diff --git a/Hawk/Hawk.psd1 b/Hawk/Hawk.psd1 index 1794b8b6..788814d2 100644 --- a/Hawk/Hawk.psd1 +++ b/Hawk/Hawk.psd1 @@ -3,7 +3,7 @@ RootModule = 'Hawk.psm1' # Version number of this module. - ModuleVersion = '4.0' + ModuleVersion = '4.0.1' # ID used to uniquely identify this module GUID = '1f6b6b91-79c4-4edf-83a1-66d2dc8c3d85' diff --git a/Hawk/changelog.md b/Hawk/changelog.md index b96db134..6e97bab1 100644 --- a/Hawk/changelog.md +++ b/Hawk/changelog.md @@ -89,7 +89,7 @@ - Updated Change Log URI. - Removed improperly formatted JSON from Get-HawkTenantAdminInboxRuleHistory, Get-HawkTenantAdminInboxRuleRemoval, Get-HawkTenantRBACChange, Get-HawkUserAdminAudit, Search-HawkTenantEXOAuditLog -## 4.0 (2025-2-XX) +## 4.0 (2025-2-23) - Implemented UTC timestamps to avoid using local timestamps - Implemented PROMPT tag to display to screen when prompting user @@ -106,3 +106,8 @@ - Implemented check to verify that an Exchange operation is enabled for auditing before attempting to pull logs - Added log pull of user Send activity to the User Investigation (Get-HawkUserMailSendActivity) - Added log pull of user SharePoint Search activity to the User Investigation (Get-HawkUserSharePointSearchQuery) + +## 4.0.1 (2025-3-0X) +- Fixed bug in Get-IPGeolocation where API Keys from ipstack.com were not validated +- Fixed bug in Get-IPGeolocation where API Keys were not validated to meet basic sanity checks/requirements +- Added commandline agrument '-EnableGeoIPLocation' to Start-HawkUserInvestigation providing the ability to skip interactive prompts when conducting investigations. \ No newline at end of file diff --git a/Hawk/debug.ps1 b/Hawk/debug.ps1 new file mode 100644 index 00000000..c92d8a0c --- /dev/null +++ b/Hawk/debug.ps1 @@ -0,0 +1,2 @@ +Import-Module "C:\Users\Lorenzo\hawk\Hawk\Hawk.psd1" +Start-HawkUserInvestigation -UserPrincipalName irelandl@semperhunt.onmicrosoft.com -DaysToLookBack 60 -FilePath C:\Temp \ No newline at end of file diff --git a/Hawk/functions/Tenant/Get-HawkTenantRiskyServicePrincipals.ps1 b/Hawk/functions/Tenant/Get-HawkTenantRiskyServicePrincipals.ps1 index b13276b3..5f87698d 100644 --- a/Hawk/functions/Tenant/Get-HawkTenantRiskyServicePrincipals.ps1 +++ b/Hawk/functions/Tenant/Get-HawkTenantRiskyServicePrincipals.ps1 @@ -65,14 +65,14 @@ Function Get-HawkTenantRiskyServicePrincipals { Send-AIEvent -Event "CmdRun" # Check for required license - $licenseCheck = Test-EntraWorkloadIDPremium - if (-not $licenseCheck.HasLicense) { - Out-LogFile "Entra Workload ID Premium license not found" -isWarning - Out-LogFile "No Entra Workload ID Premium capable licenses found." -Information - Out-LogFile "Required licenses: AAD_PREMIUM_P2, ENTERPRISEPREMIUM, SPE_E5, IDENTITY_THREAT_PROTECTION" -Information - Out-LogFile "The service principal risk detection requires one of these licenses to function" -Information - return - } + # $licenseCheck = Test-EntraWorkloadIDPremium + # if (-not $licenseCheck.HasLicense) { + # Out-LogFile "Entra Workload ID Premium license not found" -isWarning + # Out-LogFile "No Entra Workload ID Premium capable licenses found." -Information + # Out-LogFile "Required licenses: AAD_PREMIUM_P2, ENTERPRISEPREMIUM, SPE_E5, IDENTITY_THREAT_PROTECTION" -Information + # Out-LogFile "The service principal risk detection requires one of these licenses to function" -Information + # return + # } Out-LogFile "Retrieving risky service principals from Microsoft Entra ID" -Action diff --git a/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 b/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 index 621cbd36..15fe9bdc 100644 --- a/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 +++ b/Hawk/functions/Tenant/Start-HawkTenantInvestigation.ps1 @@ -195,7 +195,7 @@ Out-LogFile "Running Get-HawkTenantRBACChange" -action Get-HawkTenantRBACChange } - + if ($PSCmdlet.ShouldProcess("Entra ID Audit Log", "Get Entra ID audit logs")) { Out-LogFile "Running Get-HawkTenantEntraIDAuditLog" -action Get-HawkTenantEntraIDAuditLog @@ -247,6 +247,4 @@ $investigationEndTime = Get-Date Write-HawkInvestigationSummary -StartTime $investigationStartTime -EndTime $investigationEndTime -InvestigationType 'Tenant' } - - } \ No newline at end of file diff --git a/Hawk/functions/User/Get-HawkUserUALSignInLog.ps1 b/Hawk/functions/User/Get-HawkUserUALSignInLog.ps1 index e3058e13..a07431dd 100644 --- a/Hawk/functions/User/Get-HawkUserUALSignInLog.ps1 +++ b/Hawk/functions/User/Get-HawkUserUALSignInLog.ps1 @@ -13,6 +13,8 @@ Single UPN of a user, comma seperated list of UPNs, or array of objects that contain UPNs. .PARAMETER ResolveIPLocations Resolved IP Locations + + If this option is specified, it will attempt to resolve IP locations (GeoIP) using ipstack.com API (API Key Required) .OUTPUTS File: Converted_Authentication_Logs.csv @@ -100,26 +102,31 @@ } - # Add IP Geo Location information to the data - if ($ResolveIPLocations) { - Out-File "Resolving IP Locations" + # Add Geo IP location information to the data + if ($PSBoundParameters.ContainsKey('ResolveIPLocations')) { + Out-LogFile "Attemping to resolve IP Locations" -Information # Setup our counter $i = 0 - # Loop thru each connection and get the location + # Conduct IPStack API Key validation (once) so the API Key can be passed to Get-IPGeolocation + # Get-IPStackAPIKey either returns a valid API key or null + $AccessKey = Get-IPStackAPIKey + + # Loop thru each connection and get the Geo IP 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) + 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 + # Need to perform access key value only once instead of for each IP address + if($ExpandedUserLogonLogs.item($i).clientip -and ([string]::IsNullOrEmpty($AccessKey) -eq $false)) { + $Location = Get-IPGeoLocation -IPAddress $ExpandedUserLogonLogs.item($i).clientip -AccessKey $AccessKey } else { - $Location = "IP Address Null" + $Location = "Lack valid REST API key or IP address was not found" } # Combine the connection object and the location object so that we have a single output ready @@ -129,7 +136,7 @@ $i++ } - Write-Progress -Completed -Activity "Looking Up Ip Address Locations" -Status " " + Write-Progress -Completed -Activity "Looking Up IP Address Locations" -Status " " } else { Out-LogFile "ResolveIPLocations not specified" -Information @@ -144,6 +151,4 @@ } } - - } diff --git a/Hawk/functions/User/Start-HawkUserInvestigation.ps1 b/Hawk/functions/User/Start-HawkUserInvestigation.ps1 index 0121f5a8..279c4021 100644 --- a/Hawk/functions/User/Start-HawkUserInvestigation.ps1 +++ b/Hawk/functions/User/Start-HawkUserInvestigation.ps1 @@ -61,6 +61,13 @@ .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. + + .PARAMETER EnableGeoIPLocation + Switch to enable resolving IP addresses to geographic locations in the investigation. + This option requires an active internet connection and may increase the time needed to complete the investigation. + Providing this parameter automatically enables non-interactive mode. + + REQUIRED: An API key from ipstack.com is required to use this feature. .OUTPUTS Creates multiple CSV and JSON files containing investigation results. @@ -97,12 +104,12 @@ param ( [Parameter(Mandatory = $true)] [array]$UserPrincipalName, - [DateTime]$StartDate, [DateTime]$EndDate, [int]$DaysToLookBack, [string]$FilePath, - [switch]$SkipUpdate + [switch]$SkipUpdate, + [switch]$EnableGeoIPLocation ) begin { @@ -125,9 +132,17 @@ } try { - Initialize-HawkGlobalObject -StartDate $StartDate -EndDate $EndDate ` - -DaysToLookBack $DaysToLookBack -FilePath $FilePath ` - -SkipUpdate:$SkipUpdate -NonInteractive:$NonInteractive + # Call Initialize-HawkGlobalObject in case of non-interactive mode + if ($PSBoundParameters.ContainsKey('EnableGeoIPLocation')) { + Initialize-HawkGlobalObject -StartDate $StartDate -EndDate $EndDate ` + -DaysToLookBack $DaysToLookBack -FilePath $FilePath ` + -SkipUpdate:$SkipUpdate -NonInteractive:$NonInteractive -EnableGeoIPLocation:$EnableGeoIPLocation + } else { + # Call Initialize-HawkGlobalObject in case of interactive mode for EnableGeoIPLocation + Initialize-HawkGlobalObject -StartDate $StartDate -EndDate $EndDate ` + -DaysToLookBack $DaysToLookBack -FilePath $FilePath ` + -SkipUpdate:$SkipUpdate -NonInteractive:$NonInteractive + } } catch { Stop-PSFFunction -Message "Failed to initialize Hawk: $_" -EnableException $true @@ -138,8 +153,18 @@ process { if (Test-PSFFunctionInterrupt) { return } + # Be sure to remove comments after testing! + #if ($PSBoundParameters.ContainsKey('EnableGeoIPLocation')) { + # Initialize-HawkGlobalObject -EnableGeoIPLocation + # Out-LogFile "START-HAWKUSERINVESTIGATION -> Calling Initialize-HawkGlobalObject with EnableGeoIPLocation" -Information + #} else { + # Initialize-HawkGlobalObject + # Out-LogFile "START-HAWKUSERINVESTIGATION -> Calling Initialize-HawkGlobalObject without EnableGeoIPLocation" -Information + #} + # Check if Hawk object exists and is fully initialized if (Test-HawkGlobalObject) { + #Out-LogFile "START-USERINVESTIGATION::Test-HawkGlobalOjbect evaluated to TRUE!" -Information Initialize-HawkGlobalObject } $investigationStartTime = Get-Date @@ -156,6 +181,7 @@ foreach ($Object in $UserArray) { [string]$User = $Object.UserPrincipalName + <# if ($PSCmdlet.ShouldProcess("Running Get-HawkUserConfiguration for $User")) { Out-LogFile "Running Get-HawkUserConfiguration" -Action Get-HawkUserConfiguration -User $User @@ -180,12 +206,21 @@ Out-LogFile "Running Get-HawkUserEntraIDSignInLog" -Action Get-HawkUserEntraIDSignInLog -UserPrincipalName $User } - + #> if ($PSCmdlet.ShouldProcess("Running Get-HawkUserUALSignInLog for $User")) { - Out-LogFile "Running Get-HawkUserUALSignInLog" -Action - Get-HawkUserUALSignInLog -User $User -ResolveIPLocations + # Two different use cases (interactive and non-interactive) have to be considered here + # $Hawk.EnableGeoIPLocation is to account for interactive mode + # $PSBoundParameters.ContainsKey('EnableGeoIPLocation') is to account for non-interactive mode + if ($Hawk.EnableGeoIPLocation -or $PSBoundParameters.ContainsKey('EnableGeoIPLocation')) { + Out-LogFile "Running Get-HawkUserUALSignInLog and resolving IP locations" -Action + Get-HawkUserUALSignInLog -User $User -ResolveIPLocations + } else { + Out-LogFile "Running Get-HawkUserUALSignInLog without resolving IP locations" -Action + Get-HawkUserUALSignInLog -User $User + } } + <# if ($PSCmdlet.ShouldProcess("Running Get-HawkUserMailboxAuditing for $User")) { Out-LogFile "Running Get-HawkUserMailboxAuditing" -Action Get-HawkUserMailboxAuditing -User $User @@ -224,6 +259,7 @@ Out-LogFile "Running Get-HawkUserMobileDevice" -Action Get-HawkUserMobileDevice -User $User } + #> } } diff --git a/Hawk/internal/functions/Add-HawkAppData.ps1 b/Hawk/internal/functions/Add-HawkAppData.ps1 index 2b259b74..614161f6 100644 --- a/Hawk/internal/functions/Add-HawkAppData.ps1 +++ b/Hawk/internal/functions/Add-HawkAppData.ps1 @@ -24,11 +24,11 @@ Function Add-HawkAppData { [string]$Value ) - Out-LogFile ("Adding " + $value + " to " + $Name + " in HawkAppData") -Action + 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 + $global:HawkAppData | Add-Member -MemberType NoteProperty -Name $Name -Value $Value -Force } else { $global:HawkAppData = New-Object -TypeName PSObject diff --git a/Hawk/internal/functions/Get-IPGeoLocation.ps1 b/Hawk/internal/functions/Get-IPGeoLocation.ps1 new file mode 100644 index 00000000..7c7b53c3 --- /dev/null +++ b/Hawk/internal/functions/Get-IPGeoLocation.ps1 @@ -0,0 +1,88 @@ +<# +.SYNOPSIS + Get-IPGeoLocation is called by Get-HawkUserUALSignInLog to resolve IP addresses to geolocation data. + An IP address and IP Stack API Key is passed to the function, as it returns a PSCustomObject with the geolocation data. + +.DESCRIPTION + Get the Geographic Location of an IP address using the ipstack.com REST API +.PARAMETER IPAddress + IP address to look up for its geographic location +.PARAMETER AccessKey + Access key for ipstack.com's REST API +.EXAMPLE + Get-IPGeoLocation -IPAddress 8.8.8.8 -AccessKey e904134b5cbb91f752a79f3ba9cbe59a + Gets all IP GeoLocation data of IPs that recieved +.NOTES + General notes +#> +function Get-IPGeoLocation { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$IPAddress, + + [Parameter(Mandatory = $true)] + [string]$AccessKey + ) + + begin {} + + process { + try { + + if ($IPAddress -eq "") { + Write-Verbose "Null IP Provided: $IPAddress" + return [PSCustomObject]@{ + IP = $IPAddress + CountryName = "NULL IP" + RegionName = "Unknown" + RegionCode = "Unknown" + ContinentName = "Unknown" + City = "Unknown" + KnownMicrosoftIP = "Unknown" + } + } + + # Check cache + if ($Global:IPLocationCache.ip -contains $IPAddress) { + Write-Verbose "IP Cache Hit: $IPAddress" + return ($Global:IPLocationCache | Where-Object { $_.ip -eq $IPAddress }) + } + + # Make API calls to IP Stack to look up IP addresses + $resource = "http://api.ipstack.com/$($IPAddress)?access_key=$AccessKey" + $geoip = Invoke-RestMethod -Method Get -URI $resource -ErrorAction Stop + $geoip | ConvertTo-Json -Depth 10 + + # Create result object + Write-Output "`n" + $isMSFTIP = Test-MicrosoftIP -IPToTest $geoip.ip -Type $geoip.type + $result = [PSCustomObject]@{ + IP = $geoip.ip + CountryName = $geoip.country_name + ContinentName = $geoip.continent_name + RegionName = $geoip.region_name + RegionCode = $geoip.region_code + City = $geoip.city + KnownMicrosoftIP = $isMSFTIP + } + + # Update cache + [array]$Global:IPLocationCache += $result + + return $result + } + catch { + Out-LogFile "Failed to retrieve location for IP $IPAddress : $_" -isError + return [PSCustomObject]@{ + IP = $IPAddress + CountryName = "Failed to Resolve" + RegionName = "Unknown" + RegionCode = "Unknown" + ContinentName = "Unknown" + City = "Unknown" + KnownMicrosoftIP = "Unknown" + } + } + } +} \ No newline at end of file diff --git a/Hawk/internal/functions/Get-IPGeolocation.ps1 b/Hawk/internal/functions/Get-IPGeolocation.ps1 deleted file mode 100644 index 62bb3c7b..00000000 --- a/Hawk/internal/functions/Get-IPGeolocation.ps1 +++ /dev/null @@ -1,108 +0,0 @@ - -<# -.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 ""){ - 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 - } -} \ No newline at end of file diff --git a/Hawk/internal/functions/Get-IPStackAPIKey.ps1 b/Hawk/internal/functions/Get-IPStackAPIKey.ps1 new file mode 100644 index 00000000..6290e87f --- /dev/null +++ b/Hawk/internal/functions/Get-IPStackAPIKey.ps1 @@ -0,0 +1,152 @@ +<# +.SYNOPSIS + Get-IPStackAPIKey is called by Get-HawkUserUALSignInLog to ensure a valid API key is available for use with ipstack.com. + Once a valid key is provided, it is saved and used by Get-IPGeolocation to resolve IP addresses to geolocation data. +.DESCRIPTION + Validate REST API key from ipstack.com +.PARAMETER None + No parameters +.EXAMPLE + [string]$AccessKey = Get-IPStackAPIKey +.NOTES + Get-IPStackAPIKey also uses Test-GeoIPAPIKey to validate the API key. +#> +function Get-IPStackAPIKey { + [CmdletBinding()] + param() + + begin { + [string]$newKey = $null + [string]$AccessKeyFromFile = $null + [string]$saveChoice = $null + [bool]$GeoIPFromCommandLine = $Global:Hawk.GeoIPNonInteractive + } + + process { + + try { + # Read in existing HawkAppData + if (!([bool](Get-Variable HawkAppData -ErrorAction SilentlyContinue))) { + Read-HawkAppData + if ($HawkAppData.access_key) { + Out-LogFile "HawkAppData JSON file read successfully." -Information + [string]$AccessKeyFromFile = $HawkAppData.access_key + } + else { + Out-LogFile "HawkAppData Access Key is null/empty." -Information + } + } + + # NON-INTERACTIVE MODE: If EnableGeoIPLocation is set to true and key exists on disk (supports Hawk automation) + # If the key comes back invalid, continue to run the program without lookuping up GeoIP data + if ($GeoIPFromCommandLine) { + if (-not [string]::IsNullOrEmpty($AccessKeyFromFile)) { + $maskedKey = "**************************" + $AccessKeyFromFile.Substring($AccessKeyFromFile.Length - 6) + Out-LogFile "GeoIP API key provided via command line: $maskedKey" -Information + $AccessKeyValid = Test-GeoIPAPIKey -Key $AccessKeyFromFile + if ($AccessKeyValid) { + Out-LogFile "GeoIP API key found on disk is valid." -Information + return $AccessKeyFromFile + } else { + Out-LogFile "GeoIP API key found on disk is invalid." -isError + return $null + } + } else { + Out-LogFile "GeoIP API key not found on disk." -isError + Out-LogFile "Continuing to process logs without GeoIP lookup information." -Information + return $null + } + } + + # Check for existing access key on disk and prompt to use it if in interactive mode + # GeoIPFromCommandLine is set to true when running in non-interactive mode + if (-not [string]::IsNullOrEmpty($AccessKeyFromFile) -and (-not $GeoIPFromCommandLine)) { + do { + $maskedKey = "**************************" + $AccessKeyFromFile.Substring($AccessKeyFromFile.Length - 6) + Out-LogFile "Found existing API key ending in: $maskedKey" -Information + Out-LogFile "Would you like to use this existing key? (Y/N): " -isPrompt -NoNewLine + $useExistingKey = (Read-Host).Trim().ToUpper() + + if ($useExistingKey -notin @('Y','N')) { + Out-LogFile "Please enter Y or N" -Information + } else { + if ($useExistingKey -eq 'Y') { + # Test existing key + Out-LogFile "Validating existing API key: $maskedKey" -Information + if (Test-GeoIPAPIKey -Key $AccessKeyFromFile) { + Out-LogFile "API KEY VALIDATED :: Using existing API key from disk -> $maskedKey" -Information + return $AccessKeyFromFile + }else { + # If access key on file is invalid, set access key to null and prompt for new key + Out-LogFile "API KEY `"$maskedKey`" INVALID :: Prompt user for new API key" -Information + $AccessKeyFromFile = $null + } + } elseif ($useExistingKey -eq 'N') { + $AccessKeyFromFile = $null + Out-LogFile "Existing API key Unkown or Disabled -> Prompt for user provided API key." -Information + } + } + } while ($useExistingKey -notin @('Y','N')) + } + + # If no existing access key is found on disk, prompt for a new one + # Check if the user is running in interactive mode + if ([string]::IsNullOrEmpty($AccessKeyFromFile) -and (-not $GeoIPFromCommandLine)) { + # Display informational messages once before looping + Out-LogFile "IpStack.com requires an API access key to gather GeoIP information." -Information + Out-LogFile "Get your free API key at: https://ipstack.com/" -Information + + # Loop until a valid key is provided + do { + # Prompt user for the API key + Out-LogFile "Please provide your IP Stack API key: " -isPrompt -NoNewLine + $newKey = (Read-Host).Trim() + + # Ensure user input provided for API key is not null or empty before testing the API key for validity + if ([string]::IsNullOrEmpty($newKey)) { + Out-LogFile "Failed to update IP Stack API key: Cannot bind argument to parameter 'Key' because it is an empty string." -isError + $isValid = $false + }else { + Out-LogFile "Validating API key: $newKey" -Action + $isValid = Test-GeoIPAPIKey -Key $newKey + } + + # If invalid, inform the user and loop again + if (-not $isValid) { + Out-LogFile "Invalid API key. Please try again." -Information + } + } while (-not $isValid) + + # Once a valid key is entered, prompt to save it + Out-LogFile "Would you like to save your API key to disk? (Y/N): " -isPrompt -NoNewLine + while ($saveChoice -notin @('Y','N')) { + $saveChoice = (Read-Host).Trim().ToUpper() + + if ($GeoIPResponse -notin @('Y','N')) { + Out-LogFile "Please enter Y or N for your response: " -isPrompt -NoNewLine + } + + if ($saveChoice -eq 'Y') { + # Save the ipstack REST API key to HawkAppData + Add-HawkAppData -name access_key -Value $newKey + $appDataPath = Join-Path $env:LOCALAPPDATA "Hawk\Hawk.json" + Out-LogFile "WARNING: Your API key has been saved to: $appDataPath" -Action + Out-LogFile "WARNING: Your API key is stored in plaintext." -Information + break + } + if ($GeoIPResponse -eq 'N') { + Out-LogFile "REST API Key for ipstack.com is not saved to disk." -Information + break + } + } + + # Return the validated key + return $newKey + } + } + catch { + Out-LogFile $_.Exception.Message -isError + throw "$($_.Exception.Message)" + } + } +} \ No newline at end of file diff --git a/Hawk/internal/functions/Initialize-HawkGlobalObject.ps1 b/Hawk/internal/functions/Initialize-HawkGlobalObject.ps1 index 6f746806..ca59db6b 100644 --- a/Hawk/internal/functions/Initialize-HawkGlobalObject.ps1 +++ b/Hawk/internal/functions/Initialize-HawkGlobalObject.ps1 @@ -24,6 +24,12 @@ .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. + .PARAMETER EnableGeoIPLocation + Switch to enable resolving IP addresses to geographic locations in the investigation. + This option requires an active internet connection and may increase the time needed to complete the investigation. + Providing this parameter automatically enables non-interactive mode. + + REQUIRED: An API key from ipstack.com is required to use this feature. .OUTPUTS Creates the $Hawk global variable and populates it with a custom PS object with the following properties @@ -48,7 +54,8 @@ [string]$FilePath, [switch]$SkipUpdate, [switch]$NonInteractive, - [switch]$Force + [switch]$Force, + [switch]$EnableGeoIPLocation ) @@ -231,12 +238,14 @@ # 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 + FilePath = $null # Will be set shortly + DaysToLookBack = $null + StartDate = $null + EndDate = $null + WhenCreated = $null + EnableGeoIPLocation = $null + TenantName = $null + GeoIPNonInteractive = $false } # Set up the file path first, before any other operations @@ -374,7 +383,6 @@ 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 @@ -523,6 +531,43 @@ } + # Be sure to remove the two comments below once you're done validating the logic + # Or if you want to be more specific and check if it was the immediate caller: + # I believe the logic is incorrect here. It should be checking if the immediate caller was UserInvestigation + # FIX-ME: modify -eq to startswith bec there are cases where we can have or and we just + # need to account for Start-HawkUserInvestigation getting called! + # FIX-ME: Account for the logic case where Get-HawkUserUALSignInLogs is called + $BoolDirectlyCalledByUserInvestigation = ((Get-PSCallStack)[1].FunctionName -like "Start-HawkUserInvestigation*") -or ` + ((Get-PSCallStack)[1].FunctionName -like "Get-HawkUserUALSignInLog*") -or ` + ((Get-PSCallStack)[1].FunctionName -like "Get-HawkUserEntraIDSignInLog*") + + if ((-not $PSBoundParameters.ContainsKey('EnableGeoIPLocation')) -and $BoolDirectlyCalledByUserInvestigation) { + Out-LogFile "Would you like to enable GeoIP Location?" -Information + Out-LogFile "An API key from ipstack.com is required." -Information + + $GeoIPResponse = '' + while ($GeoIPResponse -notin @('Y','N')) { + Out-LogFile "Enable GeoIP Location? (Y/N): " -isPrompt -NoNewLine + $GeoIPResponse = ((Read-Host).Trim()).ToUpper() + + if ($GeoIPResponse -notin @('Y','N')) { + Out-LogFile "Please enter Y or N" -Information + } + if ($GeoIPResponse -eq 'Y') { + $Hawk.EnableGeoIPLocation = $true + break + } + if ($GeoIPResponse -eq 'N') { + $Hawk.EnableGeoIPLocation = $false + break + } + } + } + + if ($PSBoundParameters.ContainsKey('EnableGeoIPLocation')) { + $Hawk.EnableGeoIPLocation = $EnableGeoIPLocation + $Hawk.GeoIPNonInteractive = $true + } # Configuration Example, currently not used @@ -533,8 +578,6 @@ } - - # Continue populating the Hawk object with other properties $Hawk.DaysToLookBack = $DaysToLookBack $Hawk.StartDate = $StartDate diff --git a/Hawk/internal/functions/Read-HawkAppData.ps1 b/Hawk/internal/functions/Read-HawkAppData.ps1 index 288626f6..3779426c 100644 --- a/Hawk/internal/functions/Read-HawkAppData.ps1 +++ b/Hawk/internal/functions/Read-HawkAppData.ps1 @@ -16,13 +16,19 @@ Function Read-HawkAppData { $HawkAppdataPath = join-path $env:LOCALAPPDATA "Hawk\Hawk.json" - # check to see if our xml file is there + # check to see if our JSON 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 + if (-not [string]::IsNullOrEmpty($global:HawkAppData.access_key)) { + Out-LogFile ("HawkAppData JSON read successfully") -Information + Out-LogFile ("HawkAppData JSON exists, not overwriting") -Information + + } + # if we don't have an JSON file then do nothing + else { + Out-LogFile ("No HawkAppData File found " + $HawkAppdataPath) -Information + Add-HawkAppData -name access_key -Value $null + } } } \ No newline at end of file diff --git a/Hawk/internal/functions/Test-GeoIPAPIKey.ps1 b/Hawk/internal/functions/Test-GeoIPAPIKey.ps1 new file mode 100644 index 00000000..f0332547 --- /dev/null +++ b/Hawk/internal/functions/Test-GeoIPAPIKey.ps1 @@ -0,0 +1,95 @@ +Function Test-GeoIPAPIKey { + <# + .SYNOPSIS + Validates the supplied data in the form of a REST API key for ipstack.com. + .DESCRIPTION + Checks if the provided API key is valid by performing format checks and making a request to ipstack.com, + using Google's DNS (8.8.8.8) for domain resolution. Returns a boolean indicating the key's validity. + .PARAMETER Key + The API key to validate. Must be a 32-character hexadecimal string. + .OUTPUTS + Boolean: $true if the API key is valid, $false otherwise. + .EXAMPLE + Test-GeoIPAPIKey -Key "your32characterhexkeyhere" + Tests whether the provided key is valid for use with ipstack.com. + #> + param ( + [Parameter(Mandatory)] + [string]$Key + ) + + process { + # Check for null or empty string + if ([string]::IsNullOrEmpty($Key)) { + Out-LogFile "Failed to update IP Stack API key: Cannot bind argument to parameter 'Key' because it is an empty string." -isError + return $false + } + + # Verify the key is exactly 32 characters + if ($Key.Length -ne 32) { + Out-LogFile "API key length is not 32 characters." -isError + return $false + } + + # Check if the key contains only hexadecimal characters (0-9, a-f) + if (-not ($Key -match '^[0-9a-f]{32}$')) { + Out-LogFile "API key contains invalid characters. Must be hexadecimal (0-9, a-f)." -isError + return $false + } + + # Resolve api.ipstack.com using Google's DNS (8.8.8.8) + try { + $dnsResult = Resolve-DnsName -Name api.ipstack.com -Server 8.8.8.8 -Type A -ErrorAction Stop + if ($null -eq $dnsResult -or $dnsResult.Count -eq 0) { + Out-LogFile "No IP addresses resolved for api.ipstack.com using Google's DNS." -isError + return $false + } + # Take the first IPv4 address + $ip = $dnsResult.IPAddress | Select-Object -First 1 + } + catch { + Out-LogFile "Failed to resolve api.ipstack.com using Google's DNS: $_" -isError + return $false + } + + # Construct the API request URI using the resolved IP and the API key + $uri = "http://$ip/check?access_key=$Key" + $headers = @{ "Host" = "api.ipstack.com" } + + # Make the API request + try { + $response = Invoke-WebRequest -Uri $uri -Headers $headers -Method Get -ErrorAction Stop + } + catch { + Out-LogFile "Failed to contact ipstack API: $_" -isError + return $false + } + + # Verify the response status code is 200 OK + if ($response.StatusCode -ne 200) { + Out-LogFile "ipstack API returned status code $($response.StatusCode), expected 200." -isError + return $false + } + + # Parse the JSON response + try { + $content = $response.Content | ConvertFrom-Json + } + catch { + Out-LogFile "Failed to parse ipstack API response: $_" -isError + return $false + } + + # Check the response for validity + # ipstack returns "success": false with an "error" object for invalid keys + if ($content.success -eq $false) { + Out-LogFile "API key validation failed: $($content.error.info)" -isError + return $false + } + else { + # Successful responses lack "success" (it's null) and contain data like "ip" + Out-LogFile "Test-GeoIPAPIKey: API Key validated successfully." -Information + return $true + } + } +} \ No newline at end of file diff --git a/Hawk/internal/functions/Test-HawkNonInteractiveMode.ps1 b/Hawk/internal/functions/Test-HawkNonInteractiveMode.ps1 index c3141cda..c13abbc2 100644 --- a/Hawk/internal/functions/Test-HawkNonInteractiveMode.ps1 +++ b/Hawk/internal/functions/Test-HawkNonInteractiveMode.ps1 @@ -38,5 +38,6 @@ Function Test-HawkNonInteractiveMode { $PSBoundParameters.ContainsKey('EndDate') -or $PSBoundParameters.ContainsKey('DaysToLookBack') -or $PSBoundParameters.ContainsKey('FilePath') -or - $PSBoundParameters.ContainsKey('SkipUpdate') + $PSBoundParameters.ContainsKey('SkipUpdate') -or + $PSBoundParameters.ContainsKey('EnableGeoIPLocation') } \ No newline at end of file diff --git a/Hawk/internal/functions/Test-MicrosoftIP.ps1 b/Hawk/internal/functions/Test-MicrosoftIP.ps1 index f75ae2b3..05914bd6 100644 --- a/Hawk/internal/functions/Test-MicrosoftIP.ps1 +++ b/Hawk/internal/functions/Test-MicrosoftIP.ps1 @@ -29,6 +29,7 @@ Function Test-MicrosoftIP { # Check if we have imported all of our IP Addresses if ($null -eq $MSFTIPList) { + Write-Output "`n" Out-Logfile "Building MSFTIPList" -Action # Load our networking dll pulled from https://github.com/lduchosal/ipnetwork diff --git a/Hawk/internal/functions/Write-HawkConfigurationComplete.ps1 b/Hawk/internal/functions/Write-HawkConfigurationComplete.ps1 index 43e9df0e..5317c909 100644 --- a/Hawk/internal/functions/Write-HawkConfigurationComplete.ps1 +++ b/Hawk/internal/functions/Write-HawkConfigurationComplete.ps1 @@ -53,10 +53,15 @@ } # Format property names and create array of formatted names + # Use Claude to help format properties $formattedNames = @() foreach ($prop in $properties) { - $name = $prop.Name -creplace '([A-Z])', ' $1' -replace '_', ' ' - $formattedNames += $name.Trim() + if ($prop.Name -eq 'EnableGeoIPLocation') { + $formattedNames += "Enable Geo IP Location" + } else { + $name = $prop.Name -creplace '([A-Z])', ' $1' -replace '_', ' ' + $formattedNames += $name.Trim() + } } # Find the longest property name