I use this Python script to check out an IP. I am in a shell all day everyday, so a quick command is less friction for me than a web interface. I store my secrets like API keys in AWS SSM Parameter store as secrets, but I still redacted them just in case.
#!/usr/bin/env python3
# Script Name: whosip
# Description: Show geo and risk information for an IP address or hostname
# Author: John C. Reid
# Created: 1/1/2019
# Modified: 4/1/2026
# Usage: whosip {ip-or-hostname}
import argparse
import os
import sys
from typing import Dict, List, Optional, Sequence, Tuple
import requests
try:
import boto3
from botocore.exceptions import BotoCoreError, ClientError
except ImportError: # pragma: no cover - dependency presence is environment-specific.
boto3 = None
BotoCoreError = ClientError = Exception
KEYCDN_URL = "https://tools.keycdn.com/geo.json"
KEYCDN_USER_AGENT = "keycdn-tools:{{REDACTED}}"
SCAMALYTICS_URL = "{{REDACTED}}"
REQUEST_TIMEOUT = 30
DEFAULT_SSM_REGION = "us-west-2"
DEFAULT_SCAMALYTICS_PARAMETER = (
"{{REDACTED}}"
)
API_KEY_PARAMETER_ENV = "SCAMALYTICS_API_KEY_PARAMETER"
API_KEY_ENV = "SCAMALYTICS_API_KEY"
RESET = "\033[0m"
BLUE = "\033[34m"
GREEN = "\033[32m"
RED = "\033[31m"
DIM = "\033[90m"
class SSMParameterError(RuntimeError):
"""Raised when a Parameter Store value cannot be retrieved."""
def fail(message: str, code: int = 1) -> None:
print(f"ERROR: {message}", file=sys.stderr)
raise SystemExit(code)
def warn(message: str) -> None:
print(f"WARNING: {message}", file=sys.stderr)
def ssm_client_for_parameter(parameter_name: str):
region = DEFAULT_SSM_REGION
if parameter_name.startswith("arn:aws:ssm:"):
arn_parts = parameter_name.split(":")
if len(arn_parts) > 3 and arn_parts[3]:
region = arn_parts[3]
else:
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or DEFAULT_SSM_REGION
return boto3.client("ssm", region_name=region)
def get_ssm_parameter(parameter_name: str) -> str:
if boto3 is None:
raise SSMParameterError(
"boto3 is required to retrieve the Scamalytics API key from Parameter Store."
)
try:
client = ssm_client_for_parameter(parameter_name)
response = client.get_parameter(Name=parameter_name, WithDecryption=True)
except (BotoCoreError, ClientError) as exc:
raise SSMParameterError(
f"Unable to retrieve Scamalytics API key from Parameter Store: {exc}"
) from exc
value = response.get("Parameter", {}).get("Value", "").strip()
if not value:
raise SSMParameterError("Retrieved Scamalytics API key from Parameter Store was empty.")
return value
def resolve_api_key(parameter_name: Optional[str]) -> str:
if parameter_name:
return get_ssm_parameter(parameter_name)
parameter_from_env = os.environ.get(API_KEY_PARAMETER_ENV, "").strip()
if parameter_from_env:
return get_ssm_parameter(parameter_from_env)
api_key = os.environ.get(API_KEY_ENV, "").strip()
if api_key:
warn("Using Scamalytics API key from environment. Prefer Parameter Store.")
return api_key
return get_ssm_parameter(DEFAULT_SCAMALYTICS_PARAMETER)
def request_json(url: str, *, params: Dict[str, str], headers: Optional[Dict[str, str]] = None) -> Dict:
try:
response = requests.get(url, params=params, headers=headers, timeout=REQUEST_TIMEOUT)
except requests.RequestException as exc:
fail(f"Unable to complete request to {url}: {exc}")
if response.status_code != 200:
fail(f"Received unexpected status code {response.status_code} from {url}.")
try:
return response.json()
except ValueError as exc:
fail(f"Response from {url} was not valid JSON: {exc}")
def as_display_value(value) -> str:
if value is None or value == "":
return "N/A"
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def keycdn_pairs(keycdn_data: Dict) -> List[Tuple[str, str]]:
geo = keycdn_data.get("data", {}).get("geo", {})
return [(key, as_display_value(value)) for key, value in geo.items()]
def scamalytics_pairs(scamalytics_data: Dict) -> List[Tuple[str, str]]:
scam = scamalytics_data.get("scamalytics", {})
if scam.get("status", "") != "ok":
return [("Scamalytics Status", as_display_value(scam.get("status", "error")))]
proxy = scam.get("scamalytics_proxy", {})
credits = scam.get("credits", {})
external = scamalytics_data.get("external_datasources", {})
firehol = external.get("firehol", {})
ipsum = external.get("ipsum", {})
spamhaus = external.get("spamhaus_drop", {})
x4bnet = external.get("x4bnet", {})
return [
("IP", as_display_value(scam.get("ip"))),
("Risk Score", as_display_value(scam.get("scamalytics_score"))),
("Risk Level", as_display_value(scam.get("scamalytics_risk"))),
("ISP Risk Score", as_display_value(scam.get("scamalytics_isp_score"))),
("ISP Risk Level", as_display_value(scam.get("scamalytics_isp_risk"))),
("Is Datacenter", as_display_value(proxy.get("is_datacenter"))),
("Is VPN", as_display_value(proxy.get("is_vpn"))),
("Blacklisted (External)", as_display_value(scamalytics_data.get("is_blacklisted_external"))),
("Firehol, Blacklisted 30", as_display_value(firehol.get("ip_blacklisted_30"))),
("Firehol, Blacklisted 1 Day", as_display_value(firehol.get("ip_blacklisted_1day"))),
("Firehol, Is Proxy", as_display_value(firehol.get("is_proxy"))),
("Ipsum, Blacklisted", as_display_value(ipsum.get("ip_blacklisted"))),
("Ipsum, Blacklists", as_display_value(ipsum.get("num_blacklists"))),
("Spamhaus DROP, Blacklisted", as_display_value(spamhaus.get("ip_blacklisted"))),
("X4B, Is VPN", as_display_value(x4bnet.get("is_vpn"))),
("X4B, Is Datacenter", as_display_value(x4bnet.get("is_datacenter"))),
("X4B, Is Tor", as_display_value(x4bnet.get("is_tor"))),
("X4B, Blacklisted Spambot", as_display_value(x4bnet.get("is_blacklisted_spambot"))),
("X4B, Bot: Opera Mini", as_display_value(x4bnet.get("is_bot_operamini"))),
("X4B, Bot: Semrush", as_display_value(x4bnet.get("is_bot_semrush"))),
("Credits Used", as_display_value(credits.get("used"))),
("Credits Remaining", as_display_value(credits.get("remaining"))),
]
def value_color(value: str) -> str:
if value == "true":
return RED
if value in {"false", "N/A"}:
return DIM
return GREEN
def print_label_value_pairs(pairs: Sequence[Tuple[str, str]]) -> None:
if not pairs:
print(f"{DIM}N/A{RESET}")
return
max_label = max(len(label) for label, _ in pairs)
for label, value in pairs:
print(f"{BLUE}{label.rjust(max_label)}{RESET}: {value_color(value)}{value}{RESET}")
def print_geo_and_risk_section(target: str, api_key_parameter: Optional[str]) -> None:
print()
keycdn_data = request_json(
KEYCDN_URL,
params={"host": target},
headers={"User-Agent": KEYCDN_USER_AGENT},
)
pairs = keycdn_pairs(keycdn_data)
resolved_ip = as_display_value(keycdn_data.get("data", {}).get("geo", {}).get("ip"))
scam_target_ip = resolved_ip if resolved_ip != "N/A" else target
try:
api_key = resolve_api_key(api_key_parameter)
except SSMParameterError as exc:
warn(str(exc))
pairs.append(("Scamalytics Status", "N/A"))
print_label_value_pairs(pairs)
print()
return
scamalytics_data = request_json(
SCAMALYTICS_URL,
params={"key": api_key, "ip": scam_target_ip},
)
pairs.extend(scamalytics_pairs(scamalytics_data))
print_label_value_pairs(pairs)
print()
def main() -> None:
parser = argparse.ArgumentParser(
description="Show geo and risk information for an IP address or hostname."
)
parser.add_argument("target", help="IP address or hostname to inspect")
parser.add_argument(
"--api-key-parameter",
help=(
"AWS Systems Manager Parameter Store parameter name containing the "
"Scamalytics API key. Defaults to SCAMALYTICS_API_KEY_PARAMETER "
f"or {DEFAULT_SCAMALYTICS_PARAMETER}."
),
)
args = parser.parse_args()
target = args.target.strip()
if not target:
fail("An IP address or hostname is required.")
print_geo_and_risk_section(target, args.api_key_parameter.strip() if args.api_key_parameter else None)
if __name__ == "__main__":
main()