IP validation tool

Session validation


During my investigations when I have duty one of the most frequent things I do is validating sessions. Most of the time I investigate 2 types of entities: The user activity and the location it’s from. The location investigation can become tedious as in the past I used Scamalytics and spur.us. Spur.us however changed their GUI and adjusted their limit rates, thus when I am on my corporate VPN I can never use spur.us because the night shift probably already used up all the API calls. One thing that verifies behaviour though is the VPN operator. Seeing “Nord VPN” for instance on sign in sessions 10x in the past and now for the 10th time this user generates an “Anonymous sign in”, it helps to see that this is anonymous IP belongs to “Nord VPN”. I also integrated abuseipdb to give more context and see any reporting categories.

So not being able to do this, or it takes 5 minutes longer per investigation is tedious to say the least. Something trivial but necesary should be simple and fast. To streamline this workflow, I created a PowerShell based IP session tool that generates a triage based on the properties of the IP address through scamalytics and proxycheck (my new spur). The function queries Scamalytics for infrastructure properties and location, and ProxyCheck for VPN attribution Abuseipdb for reports and morecontext. The results are merged into a structured format that can be pasted directly into investigation notes or incident timelines.

The tool also integrates with Microsoft PowerShell SecretManagement and SecretStore to securely store API keys. Secrets remain locked by default, are unlocked only during analyst usage, and automatically lock again after the configured timeout. This is why I like blogging because this also forces me to think about handling secrets correctly. I am in no way shape or form a developer so if there is feedback if this can be better or safer or other ways please do comment and let me know!

The sections below describes the setup, secretvault configuration, script, and usage examples.

Disclaimer: All api keys can (for the time being) be atained for free. The limits are of course low but in my case and I hope for your work/life balance as well, enough for 1 SOC analyst.

Get-ScamSpurTriage (Powershell Function)

Note

This is a legacy name. Spur does not support this, I now use Proxycheck.io. I like the name, for now it stays. :) Feel free to change the name and/or adjust the triage output to fit your style.

Requirements: You need API keys for

  • Scamalytics API v3 for location, ISP, risk score, and datacenter/TOR context
    • Scamalytics
    • Here you will receive an API User and API Key.
  • ProxyCheck v3 for VPN provider attribution, VPN/proxy confirmation, and first-seen timestamp
  • AbuseIPDB API for more context and potential reports
  • Microsoft PowerShell SecretManagement / SecretStore for secure local secret retrieval

Free tier is sufficient for 1 soc analyst. Do not use per department or team. This is for a single user. If you want this for a whole SOC the free tier will not suffice.

Prerequisites

Install the required modules:

Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser
Install-Module Microsoft.PowerShell.SecretStore -Scope CurrentUser

Current vault model

This setup assumes:

  • Vault name is SecretVault
  • The vault uses Password authentication
  • The vault needs to be unlocked by the SecretVault password once every 4 hours
  • Timeout is 4 hours
  • First time running this script will ask for password for SecretVault to unlock it. Then it will either timeout after 4 hours or if you stop the terminal session you will need to unlock it again.

Required secret names

Store these secrets in SecretVault:

  • ScamalyticsUser
  • ScamalyticsKey
  • ProxyCheckKey
  • AbuseIPDBKey

You will end up with:

  • Vault that is locked by default
  • Can only be unlocked by password in current user session
  • Unlock required once per session (session assumes a SOC shift of 4 hours, please do adjust to your needs)
  • Auto-lock after 4 hours or if the session is terminated
  • Store the secretvault password in your password manager of choice.

Vault registration

Now we create the SecretVault with the following command:

Register-SecretVault -Name SecretVault -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault

SecretStore configuration

This configuration sets the PowerShell SecretStore to use password authentication with an interactive prompt and unlocks the vault for 4 hours. When a secret is accessed and the store is locked, PowerShell prompts for the password, after which all secrets in the SecretStore backend become available until the timeout expires.

The setting applies to the SecretStore backend, meaning all vaults registered using Microsoft.PowerShell.SecretStore have this configuration applied. No other vault types are affected such as Az.Keyvault or any other vault with an API. After the timeout, the store locks again and requires the password to continue. (Like I mentioned already 4 times)

Set-SecretStoreConfiguration `
    -Authentication Password `
    -PasswordTimeout 14400 `
    -Interaction Prompt

Store the API secrets in the vault

Set-Secret -Vault SecretVault -Name ScamalyticsUser -Secret "YOUR_SCAMALYTICS_USER"
Set-Secret -Vault SecretVault -Name ScamalyticsKey -Secret "YOUR_SCAMALYTICS_KEY"
Set-Secret -Vault SecretVault -Name ProxyCheckKey -Secret "YOUR_PROXYCHECK_KEY"
Set-Secret -Vault SecretVault -Name AbuseIPDBKey -Secret "YOUR_ABUSEIPDB_KEY"

Script

function Get-ScamSpurTriage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string[]]$IPs,

        [Parameter(Mandatory = $false)]
        [string]$BaseUrl = "https://api12.scamalytics.com/v3",

        [Parameter(Mandatory = $false)]
        [string]$VaultName = "SecretVault",

        [Parameter(Mandatory = $false)]
        [string]$ScamalyticsUserSecretName = "ScamalyticsUser",

        [Parameter(Mandatory = $false)]
        [string]$ScamalyticsKeySecretName = "ScamalyticsKey",

        [Parameter(Mandatory = $false)]
        [string]$ProxyCheckKeySecretName = "ProxyCheckKey",

        [Parameter(Mandatory = $false)]
        [string]$AbuseIPDBKeySecretName = "AbuseIPDBKey",

        [Parameter(Mandatory = $false)]
        [int]$AbuseIPDBMaxAgeDays = 90,

        [Parameter(Mandatory = $false)]
        [int]$MaxRetries = 3,

        [Parameter(Mandatory = $false)]
        [int]$InitialRetryDelaySeconds = 2
    )

    function Invoke-WithRetry {
        param(
            [Parameter(Mandatory = $true)]
            [scriptblock]$ScriptBlock,

            [Parameter(Mandatory = $true)]
            [string]$OperationName,

            [Parameter(Mandatory = $false)]
            [int]$Retries = 3,

            [Parameter(Mandatory = $false)]
            [int]$InitialDelaySeconds = 2
        )

        $attempt = 0
        $delay = $InitialDelaySeconds

        while ($attempt -lt $Retries) {
            try {
                return & $ScriptBlock
            }
            catch {
                $attempt++

                if ($attempt -ge $Retries) {
                    throw
                }

                Write-Verbose "$OperationName failed on attempt $attempt. Retrying in $delay second(s)."
                Start-Sleep -Seconds $delay
                $delay = [Math]::Min($delay * 2, 30)
            }
        }
    }

    function Test-ValidIpAddress {
        param(
            [Parameter(Mandatory = $true)]
            [string]$InputIp
        )

        $nullIp = $null
        return [System.Net.IPAddress]::TryParse($InputIp, [ref]$nullIp)
    }

    function Add-UniqueItem {
        param(
            [Parameter(Mandatory = $true)]
            $List,

            [Parameter(Mandatory = $true)]
            [string]$Value
        )

        if ($null -ne $List -and -not [string]::IsNullOrWhiteSpace($Value) -and -not $List.Contains($Value)) {
            [void]$List.Add($Value)
        }
    }

    function Convert-AbuseIPDBCategory {
        param(
            [Parameter(Mandatory = $true)]
            [int]$CategoryId
        )

        $map = @{
            3  = "Fraud Orders"
            4  = "DDoS Attack"
            5  = "FTP Brute-Force"
            6  = "Ping of Death"
            7  = "Phishing"
            8  = "Fraud VoIP"
            9  = "Open Proxy"
            10 = "Web Spam"
            11 = "Email Spam"
            12 = "Blog Spam"
            13 = "VPN IP"
            14 = "Port Scan"
            15 = "Hacking"
            16 = "SQL Injection"
            17 = "Spoofing"
            18 = "Brute-Force"
            19 = "Bad Web Bot"
            20 = "Exploited Host"
            21 = "Web App Attack"
            22 = "SSH"
            23 = "IoT Targeted"
        }

        if ($map.ContainsKey($CategoryId)) {
            return $map[$CategoryId]
        }

        return "Category $CategoryId"
    }

    try {
        $null = Get-SecretVault -Name $VaultName -ErrorAction Stop

        $storeStatus = Get-SecretStoreConfiguration -ErrorAction Stop
        if ($storeStatus.Authentication -eq 'Password') {
            Write-Verbose "SecretStore uses password authentication. Unlock it first with Unlock-SecretStore."
        }

        $ApiUser          = Get-Secret -Vault $VaultName -Name $ScamalyticsUserSecretName -AsPlainText -ErrorAction Stop
        $ApiKey           = Get-Secret -Vault $VaultName -Name $ScamalyticsKeySecretName -AsPlainText -ErrorAction Stop
        $ProxyCheckApiKey = Get-Secret -Vault $VaultName -Name $ProxyCheckKeySecretName -AsPlainText -ErrorAction Stop
        $AbuseIPDBApiKey  = Get-Secret -Vault $VaultName -Name $AbuseIPDBKeySecretName -AsPlainText -ErrorAction Stop
    }
    catch {
        throw "Failed to retrieve secrets from vault '$VaultName'. If the vault is password protected, run Unlock-SecretStore first. $($_.Exception.Message)"
    }

    if (-not $IPs -or $IPs.Count -eq 0) {
        $inputIPs = Read-Host "Enter IP(s) (comma separated)"
        $IPs = $inputIPs -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ }
    }

    foreach ($rawIp in $IPs) {
        $ip = $rawIp.Trim()

        if (-not (Test-ValidIpAddress -InputIp $ip)) {
            Write-Warning "Skipping invalid IP: ${ip}"
            continue
        }

        $location = $null
        $isp = $null
        $score = $null
        $risk = $null
        $provider = $null
        $proxyCheckVpnProxy = $null
        $firstSeen = $null
        $lastSeen = $null

        $abuseConfidence = $null
        $abuseReports = $null
        $abuseLastReported = $null
        $abuseUsageType = $null
        $abuseDomain = $null
        $abuseWhitelisted = $null

        $labels = New-Object 'System.Collections.Generic.List[string]'
        $subTypes = New-Object 'System.Collections.Generic.List[string]'
        $abuseLatestCategories = New-Object 'System.Collections.Generic.List[string]'

        try {
            $scamUrl = "${BaseUrl}/${ApiUser}/?key=${ApiKey}&ip=${ip}"
            Write-Verbose "Requesting Scamalytics for ${ip}"

            $resp = Invoke-WithRetry -OperationName "Scamalytics request for ${ip}" -Retries $MaxRetries -InitialDelaySeconds $InitialRetryDelaySeconds -ScriptBlock {
                Invoke-RestMethod -Uri $scamUrl -Method Get -ErrorAction Stop
            }

            $scam = $resp.scamalytics
            $ext  = $resp.external_datasources

            if ($scam.status -eq "ok") {
                if ($null -ne $scam.scamalytics_score) {
                    $score = $scam.scamalytics_score
                }

                if (-not [string]::IsNullOrWhiteSpace([string]$scam.scamalytics_risk)) {
                    $risk = [string]$scam.scamalytics_risk
                }

                $dbip = $ext.dbip
                $mm   = $ext.maxmind_geolite2

                $city = $null
                $country = $null

                if ($dbip) {
                    if (-not [string]::IsNullOrWhiteSpace([string]$dbip.ip_city)) {
                        $city = [string]$dbip.ip_city
                    }
                    if (-not [string]::IsNullOrWhiteSpace([string]$dbip.ip_country_name)) {
                        $country = [string]$dbip.ip_country_name
                    }
                }

                if ([string]::IsNullOrWhiteSpace($city) -and $mm -and -not [string]::IsNullOrWhiteSpace([string]$mm.ip_city)) {
                    $city = [string]$mm.ip_city
                }

                if ([string]::IsNullOrWhiteSpace($country) -and $mm -and -not [string]::IsNullOrWhiteSpace([string]$mm.ip_country_name)) {
                    $country = [string]$mm.ip_country_name
                }

                if (-not [string]::IsNullOrWhiteSpace($city) -and -not [string]::IsNullOrWhiteSpace($country)) {
                    $location = "$city, $country"
                }
                elseif (-not [string]::IsNullOrWhiteSpace($country)) {
                    $location = $country
                }

                if ($dbip -and -not [string]::IsNullOrWhiteSpace([string]$dbip.isp_name)) {
                    $isp = [string]$dbip.isp_name
                }
                elseif (-not [string]::IsNullOrWhiteSpace([string]$scam.scamalytics_isp)) {
                    $isp = [string]$scam.scamalytics_isp
                }
                elseif ($mm -and -not [string]::IsNullOrWhiteSpace([string]$mm.as_name)) {
                    $isp = [string]$mm.as_name
                }

                if ($scam.scamalytics_proxy.is_datacenter -eq $true) {
                    Add-UniqueItem $labels "Datacenter"
                }

                if ($scam.scamalytics_proxy.is_vpn -eq $true) {
                    Add-UniqueItem $labels "VPN"
                }

                if ($scam.scamalytics_proxy.is_google -eq $true) {
                    Add-UniqueItem $labels "Google Infrastructure"
                }

                if ($scam.scamalytics_proxy.is_amazon_aws -eq $true) {
                    Add-UniqueItem $labels "AWS"
                }

                if ($scam.scamalytics_isp -match "Microsoft" -or
                    $ext.maxmind_geolite2.as_name -match "Microsoft" -or
                    $ext.ipinfo.as_name -match "Microsoft") {

                    Add-UniqueItem $labels "Microsoft Infrastructure"
                }

                if ($scam.scamalytics_proxy.is_apple_icloud_private_relay -eq $true) {
                    Add-UniqueItem $labels "Apple iCloud Relay"
                }

                if ($ext.x4bnet.is_tor -eq $true) {
                    Add-UniqueItem $labels "TOR"
                }

                if ($ext.x4bnet.is_vpn -eq $true) {
                    Add-UniqueItem $labels "VPN"
                }

                if ($ext.x4bnet.is_datacenter -eq $true) {
                    Add-UniqueItem $labels "Datacenter"
                }

                if ($ext.firehol.is_proxy -eq $true) {
                    Add-UniqueItem $labels "Proxy"
                }

                if ($ext.firehol.ip_blacklisted_30 -eq $true -or $ext.firehol.ip_blacklisted_1day -eq $true) {
                    Add-UniqueItem $labels "Blacklist: Firehol"
                }

                if ($ext.ipsum.ip_blacklisted -eq $true -or ($null -ne $ext.ipsum.num_blacklists -and [int]$ext.ipsum.num_blacklists -gt 0)) {
                    Add-UniqueItem $labels "Blacklist: IPsum"
                }

                if ($ext.spamhaus_drop.ip_blacklisted -eq $true) {
                    Add-UniqueItem $labels "Blacklist: Spamhaus"
                }

                if ($ext.x4bnet.is_blacklisted_spambot -eq $true) {
                    Add-UniqueItem $labels "Blacklist: X4Bnet Spambot"
                }

                if ($ext.google.is_googlebot -eq $true -or $ext.google.is_special_crawler -eq $true) {
                    Add-UniqueItem $labels "Search Engine Robot"
                }

                if ($ext.ip2proxy) {
                    switch ([string]$ext.ip2proxy.proxy_type) {
                        "VPN" { Add-UniqueItem $labels "VPN" }
                        "TOR" { Add-UniqueItem $labels "TOR" }
                        "DCH" { Add-UniqueItem $labels "Datacenter" }
                        "PUB" {
                            Add-UniqueItem $labels "Proxy"
                            Add-UniqueItem $subTypes "Public Proxy"
                        }
                        "WEB" {
                            Add-UniqueItem $labels "Proxy"
                            Add-UniqueItem $subTypes "Web Proxy"
                        }
                        "SES" { Add-UniqueItem $labels "Search Engine Robot" }
                        "RES" { Add-UniqueItem $subTypes "Residential" }
                        "MOB" { Add-UniqueItem $subTypes "Mobile" }
                    }
                }

                if ($ext.ip2proxy_lite) {
                    if ($ext.ip2proxy_lite.ip_blacklisted -eq $true) {
                        Add-UniqueItem $labels "Blacklist: IP2ProxyLite"
                    }

                    switch ([string]$ext.ip2proxy_lite.proxy_type) {
                        "VPN" { Add-UniqueItem $labels "VPN" }
                        "TOR" { Add-UniqueItem $labels "TOR" }
                        "DCH" { Add-UniqueItem $labels "Datacenter" }
                        "PUB" {
                            Add-UniqueItem $labels "Proxy"
                            Add-UniqueItem $subTypes "Public Proxy"
                        }
                        "WEB" {
                            Add-UniqueItem $labels "Proxy"
                            Add-UniqueItem $subTypes "Web Proxy"
                        }
                        "SES" { Add-UniqueItem $labels "Search Engine Robot" }
                    }
                }
            }
            else {
                $risk = "API status: $($scam.status)"
            }
        }
        catch {
            Write-Verbose "Scamalytics failed for ${ip}. $($_.Exception.Message)"
        }

        try {
            $pcUrl = "https://proxycheck.io/v3/${ip}?key=${ProxyCheckApiKey}&vpn=1&asn=1"
            Write-Verbose "Requesting ProxyCheck for ${ip}"

            $pcResp = Invoke-WithRetry -OperationName "ProxyCheck request for ${ip}" -Retries $MaxRetries -InitialDelaySeconds $InitialRetryDelaySeconds -ScriptBlock {
                Invoke-RestMethod -Uri $pcUrl -Method Get -ErrorAction Stop
            }

            if ($pcResp.status -in @("ok", "warning")) {
                $pcProperty = $pcResp.PSObject.Properties | Where-Object { $_.Name -eq $ip } | Select-Object -First 1

                if ($null -ne $pcProperty) {
                    $pcData = $pcProperty.Value

                    if ($null -ne $pcData.detections) {
                        $pcVpn = $pcData.detections.vpn
                        $pcProxy = $pcData.detections.proxy
                        $pcTor = $pcData.detections.tor
                        $pcHosting = $pcData.detections.hosting
                        $pcAnonymous = $pcData.detections.anonymous

                        if ($null -ne $pcVpn -or $null -ne $pcProxy) {
                            $proxyCheckVpnProxy = "$pcVpn / $pcProxy"
                        }

                        if (-not [string]::IsNullOrWhiteSpace([string]$pcData.detections.first_seen)) {
                            $firstSeen = [string]$pcData.detections.first_seen
                        }

                        if (-not [string]::IsNullOrWhiteSpace([string]$pcData.detections.last_seen)) {
                            $lastSeen = [string]$pcData.detections.last_seen
                        }

                        if ($pcVpn -eq $true) {
                            Add-UniqueItem $labels "VPN"
                        }

                        if ($pcProxy -eq $true) {
                            Add-UniqueItem $labels "Proxy"
                        }

                        if ($pcTor -eq $true) {
                            Add-UniqueItem $labels "TOR"
                        }

                        if ($pcHosting -eq $true) {
                            Add-UniqueItem $labels "Datacenter"
                        }

                        if ($pcAnonymous -eq $true) {
                            Add-UniqueItem $subTypes "Anonymous"
                        }
                    }

                    $mainOperator = $null
                    $additionalOperators = @()

                    if ($null -ne $pcData.operator) {
                        if (-not [string]::IsNullOrWhiteSpace([string]$pcData.operator.name)) {
                            $mainOperator = [string]$pcData.operator.name
                        }

                        if ($null -ne $pcData.operator.additional_operators) {
                            if ($pcData.operator.additional_operators -is [System.Array]) {
                                $additionalOperators = $pcData.operator.additional_operators | Where-Object {
                                    -not [string]::IsNullOrWhiteSpace([string]$_)
                                }
                            }
                            elseif (-not [string]::IsNullOrWhiteSpace([string]$pcData.operator.additional_operators)) {
                                $additionalOperators = @([string]$pcData.operator.additional_operators)
                            }
                        }
                    }

                    if (-not [string]::IsNullOrWhiteSpace($mainOperator) -and $additionalOperators.Count -gt 0) {
                        $provider = "$mainOperator, with additional overlap noted for $($additionalOperators -join ', ')"
                    }
                    elseif (-not [string]::IsNullOrWhiteSpace($mainOperator)) {
                        $provider = $mainOperator
                    }

                    if (-not [string]::IsNullOrWhiteSpace($provider) -and $pcData.detections.vpn -eq $true) {
                        while ($labels.Contains("VPN")) {
                            [void]$labels.Remove("VPN")
                        }
                        Add-UniqueItem $labels "VPN: $provider"
                    }

                    if ([string]::IsNullOrWhiteSpace($location) -and $null -ne $pcData.location) {
                        $pcCity = [string]$pcData.location.city_name
                        $pcCountry = [string]$pcData.location.country_name

                        if (-not [string]::IsNullOrWhiteSpace($pcCity) -and -not [string]::IsNullOrWhiteSpace($pcCountry)) {
                            $location = "$pcCity, $pcCountry"
                        }
                        elseif (-not [string]::IsNullOrWhiteSpace($pcCountry)) {
                            $location = $pcCountry
                        }
                    }

                    if ([string]::IsNullOrWhiteSpace($isp) -and $null -ne $pcData.network -and -not [string]::IsNullOrWhiteSpace([string]$pcData.network.provider)) {
                        $isp = [string]$pcData.network.provider
                    }
                }
                else {
                    Write-Verbose "ProxyCheck returned status ok, but no IP result block was found for ${ip}"
                }
            }
            else {
                Write-Verbose "ProxyCheck status was $($pcResp.status)"
            }
        }
        catch {
            Write-Verbose "ProxyCheck failed for ${ip}. $($_.Exception.Message)"
        }

        try {
            $encodedIp = [System.Uri]::EscapeDataString($ip)
            $abuseUrl = "https://api.abuseipdb.com/api/v2/check?ipAddress=$encodedIp&maxAgeInDays=$AbuseIPDBMaxAgeDays&verbose"
            Write-Verbose "Requesting AbuseIPDB for ${ip}"

            $abuseResp = Invoke-WithRetry -OperationName "AbuseIPDB request for ${ip}" -Retries $MaxRetries -InitialDelaySeconds $InitialRetryDelaySeconds -ScriptBlock {
                Invoke-RestMethod -Uri $abuseUrl -Method Get -Headers @{
                    Key    = $AbuseIPDBApiKey
                    Accept = "application/json"
                } -ErrorAction Stop
            }

            if ($null -ne $abuseResp.data) {
                $abuse = $abuseResp.data

                $abuseConfidence = $abuse.abuseConfidenceScore
                $abuseReports = $abuse.totalReports
                $abuseLastReported = $abuse.lastReportedAt
                $abuseUsageType = $abuse.usageType
                $abuseDomain = $abuse.domain
                $abuseWhitelisted = $abuse.isWhitelisted

                if ([string]::IsNullOrWhiteSpace($isp) -and -not [string]::IsNullOrWhiteSpace([string]$abuse.isp)) {
                    $isp = [string]$abuse.isp
                }

                if ([string]::IsNullOrWhiteSpace($location) -and -not [string]::IsNullOrWhiteSpace([string]$abuse.countryName)) {
                    $location = [string]$abuse.countryName
                }

                if ($abuse.isTor -eq $true) {
                    Add-UniqueItem $labels "TOR"
                }

                if (-not [string]::IsNullOrWhiteSpace([string]$abuse.usageType)) {
                    switch -Regex ([string]$abuse.usageType) {
                        "Data Center|Web Hosting|Transit" {
                            Add-UniqueItem $labels "Datacenter"
                        }
                        "Search Engine Spider" {
                            Add-UniqueItem $labels "Search Engine Robot"
                        }
                        "Content Delivery Network" {
                            Add-UniqueItem $subTypes "CDN"
                        }
                    }
                }

                if ($null -ne $abuse.reports -and $abuse.reports.Count -gt 0) {
                    $latestReports = $abuse.reports |
                        Sort-Object { [datetime]$_.reportedAt } -Descending |
                        Select-Object -First 50

                    foreach ($report in $latestReports) {
                        foreach ($categoryId in $report.categories) {
                            Add-UniqueItem $abuseLatestCategories (Convert-AbuseIPDBCategory -CategoryId ([int]$categoryId))
                        }
                    }
                }
            }
        }
        catch {
            Write-Verbose "AbuseIPDB failed for ${ip}. $($_.Exception.Message)"
        }

        $headerLabels = ($labels | ForEach-Object { "[$_]" }) -join " "

        if ([string]::IsNullOrWhiteSpace($headerLabels)) {
            Write-Output "##### $ip"
        }
        else {
            Write-Output "##### $headerLabels $ip"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($location)) {
            Write-Output "- [Scamalytics] Location: $location"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($isp)) {
            Write-Output "- [Scamalytics] ISP: $isp"
        }
        
        if ($ext -and $ext.dbip -and -not [string]::IsNullOrWhiteSpace([string]$ext.dbip.connection_type)) {
            Write-Output "- [Scamalytics: Connection]: $([string]$ext.dbip.connection_type)"
        }
        
        if ($pcData -and $pcData.network -and -not [string]::IsNullOrWhiteSpace([string]$pcData.network.type)) {
            Write-Output "- [ProxyCheck: Connection]: $([string]$pcData.network.type)"
        }
        
        if (-not [string]::IsNullOrWhiteSpace([string]$abuseUsageType)) {
            Write-Output "- [AbuseIPDB: Usage]: $abuseUsageType"
        }
        
        ## Write-Output "- Labels: $(($labels -join ', '))"
        
        if ($subTypes.Count -gt 0) {
            Write-Output "- [ProxyCheck] Subtype(s): $(($subTypes -join ', '))"
        }
        
        if ($null -ne $score -or -not [string]::IsNullOrWhiteSpace($risk)) {
            $scoreText = if ($null -ne $score) { $score } else { "Unknown" }
            $riskText  = if (-not [string]::IsNullOrWhiteSpace($risk)) { $risk } else { "Unknown" }
            Write-Output "- [Scamalytics] risk: $scoreText ($riskText)"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($provider)) {
            Write-Output "- [ProxyCheck] Provider: $provider"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($proxyCheckVpnProxy)) {
            Write-Output "- [ProxyCheck] VPN/Proxy: $proxyCheckVpnProxy"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($firstSeen)) {
            Write-Output "- [ProxyCheck] first seen: $firstSeen"
        }
        
        if (-not [string]::IsNullOrWhiteSpace($lastSeen)) {
            Write-Output "- [ProxyCheck] last seen: $lastSeen"
        }
        
        if ($null -ne $abuseConfidence) {
            Write-Output "- [AbuseIPDB] confidence: $abuseConfidence"
        }
        
        if ($null -ne $abuseReports) {
            Write-Output "- [AbuseIPDB] reports: $abuseReports in last $AbuseIPDBMaxAgeDays days"
        }
        
        if ($abuseReports -gt 0 -and $abuseLastReported) {
            Write-Output "- [AbuseIPDB] last reported: $abuseLastReported"
        }
        
        if (-not [string]::IsNullOrWhiteSpace([string]$abuseDomain)) {
            Write-Output "- [AbuseIPDB] domain: $abuseDomain"
        }
        
        if ($null -ne $abuseWhitelisted) {
            Write-Output "- [AbuseIPDB] whitelisted: $abuseWhitelisted"
        }
        
        if ($abuseWhitelisted -eq $true) {
            Write-Output "- [AbuseIPDB] IMPORTANT NOTE: IP is whitelisted. Whitelisted netblocks often belong to trusted providers but may still host abused cloud infrastructure. Validate context before trusting."
        }
        
        if ($abuseLatestCategories.Count -gt 0) {
            Write-Output "- [AbuseIPDB] latest categories: $(($abuseLatestCategories -join ', '))"
        }
        
        Write-Output ""
    }
}

How to use

Note: IP v4 can be with or with out quotes however IPv6 needs to be with in quotes.

Interactive input

Get-ScamSpurTriage

Single IP

Get-ScamSpurTriage -IPs "1.1.1.1"

Multiple IPs

Get-ScamSpurTriage -IPs "1.1.1.1","8.8.8.8"

Verbose logging

Get-ScamSpurTriage -IPs "1.1.1.1" -Verbose

Example output

##### [Datacenter] [Blacklist: IPsum] 198.235.24.79
- [Scamalytics] Location: Santa Clara, United States
- [Scamalytics] ISP: Google LLC
- [Scamalytics] risk: 0 (low)
- [ProxyCheck] VPN/Proxy: False / False
- [AbuseIPDB] confidence: 0
- [AbuseIPDB] reports: 4587 in last 90 days
- [AbuseIPDB] last reported: 2026-03-29T11:34:41+00:00
- [AbuseIPDB] usage type: Data Center/Web Hosting/Transit
- [AbuseIPDB] domain: paloaltonetworks.com
- [AbuseIPDB] whitelisted: True
- [AbuseIPDB] IMPORTANT NOTE: IP is whitelisted. Whitelisted netblocks often belong to trusted providers but may still host abused cloud infrastructure. Validate context before trusting.
- [AbuseIPDB] latest categories: Port Scan, Brute-Force, SSH, Hacking, Bad Web Bot, Web App Attack, IoT Targeted, Spoofing

Operational flow

  1. Start script/function
  2. Unlock vault with password pasted from password manager
  3. Secrets retrieved from SecretVault
  4. Execute function
  5. Triage generated
  6. Vault auto-locks after 4 hours or when session is terminated

Field explanation

Header Labels

The labels shown in the header provide a quick classification of the IP address. These are derived from Scamalytics, ProxyCheck, and AbuseIPDB combined. Multiple labels may be present.

Examples:

  • Datacenter
  • VPN
  • VPN: NordVPN
  • TOR
  • Proxy
  • Google Infrastructure
  • Microsoft Infrastructure
  • AWS
  • Search Engine Robot
  • Blacklist: Firehol
  • Blacklist: Spamhaus

These labels are meant to give more context to the type of IP.

Location

Geolocation of the IP address. Primarily derived from Scamalytics external data sources (DB-IP / MaxMind), with fallback to ProxyCheck if unavailable.

Format:

  • City, Country

ISP

Primary ISP or ASN owner of the IP address. This is typically the infrastructure owner and not necessarily the actual end user.

Examples:

  • Microsoft Corporation
  • Google LLC
  • Amazon Technologies Inc.
  • Datacamp Limited

{Provider}: Connection

Gives context to the connection type such as a Home ISP or a Wireless connection. NOTE: Please take into account that different providers can give different connection type. Please investigate what is appropiate in your situation. AbuseIPDB might give a wiress verdict and Scamalytics might say Residential. Adjust the triage if needed.

Example:

  • Residential
    • (Home Use)
  • Wireless
    • (Mobile Use)
  • Hosting
    • (Datacenter)

Risk

Scamalytics fraud risk score and classification.

Format:

  • [score] (risk level)

Example:

  • 100 (very high)

Provider

VPN or proxy operator attribution from ProxyCheck. When a provider is identified, the generic VPN label is replaced with a provider-specific label.

Examples:

  • NordVPN
  • Mullvad
  • ProtonVPN
  • TOR

Header example:

  • [VPN: NordVPN]

ProxyCheck VPN/Proxy

Boolean detection from ProxyCheck indicating whether the IP is detected as a VPN or proxy.

Format:

  • VPN / Proxy

Example:

  • True / False

First seen

First observed timestamp for VPN/proxy detection from ProxyCheck. Indicates when the IP was first identified as belonging to the detected provider.

Last seen

Most recent timestamp ProxyCheck observed the IP as VPN/proxy infrastructure. Useful to determine whether the detection is recent or stale. However I am debating if I should keep this because we are investigating an active incident thus the last seen for the analyst would of course be “now” however it can give additional context if the last seen is not recent.

AbuseIPDB confidence

Abuse confidence score from AbuseIPDB. Higher values indicate stronger consensus of malicious activity.

Range:

  • 0–100

AbuseIPDB reports

Number of reports submitted to AbuseIPDB within the configured time window (default 90 days).

Format:

  • X in last 90 days

AbuseIPDB last reported

Timestamp of the most recent report. Only shown when reports exist within the configured window.

AbuseIPDB usage type

Infrastructure classification provided by AbuseIPDB.

Examples:

  • Data Center/Web Hosting/Transit
  • Content Delivery Network
  • Fixed Line ISP
  • Search Engine Spider

This may also influence subtype classification.

AbuseIPDB domain

Domain associated with the IP address according to AbuseIPDB. Often useful for identifying infrastructure ownership.

Examples:

  • google.com
  • microsoft.com
  • amazon.com

AbuseIPDB whitelisted

Indicates whether the IP belongs to a trusted infrastructure block maintained by AbuseIPDB.

Whitelisted IPs may still be abused because they often belong to large cloud or CDN providers.

When true, an additional warning is shown. This can be removed in the triage, depending on need. This serves more as a additional context for me (the analyst).

AbuseIPDB latest categories

Unique abuse categories extracted from the most recent AbuseIPDB reports (latest 50 entries). These provide context about observed malicious behavior.

Examples:

  • Port Scan
  • Brute-Force
  • Bad Web Bot
  • Exploited Host
  • Web App Attack