Security · python · 20 copies
Threat Intelligence Lookup
Multi-source threat intelligence lookup for IPs, domains, file hashes (MD5/SHA1/SHA256), and URLs. Queries VirusTotal v3, AbuseIPDB v2, MalwareBazaar, and ip-api.com geolocation. Checks Tor exit node status. Produces per-IOC verdict (MALICIOUS/SUSPICIOUS/CLEAN/UNKNOWN) with remediation actions. Ideal for SOC analysts and incident response.
- Linux
- macOS
- Windows
$
pip install "requests>=2.28"terminal
threat_intel_lookup.py
#!/usr/bin/env python3
"""
Threat Intelligence Lookup
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/threat-intel-lookup
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "requests>=2.28"
"""
import argparse, hashlib, json, os, sys, time
from datetime import datetime
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
import requests
except ImportError:
print("[!] pip install requests")
sys.exit(1)
# --- API configuration ---
VT_API_KEY = os.environ.get("VT_API_KEY", "") # VirusTotal v3
ABUSEIPDB_KEY = os.environ.get("ABUSEIPDB_KEY", "") # AbuseIPDB v2
SHODAN_KEY = os.environ.get("SHODAN_KEY", "") # Shodan (optional)
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "ThreatIntelLookup/1.0"})
TIMEOUT = 15
def classify_ioc(ioc: str) -> str:
import re
ioc = ioc.strip()
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ioc):
return "ipv4"
if re.match(r"^[0-9a-fA-F]{32}$", ioc):
return "md5"
if re.match(r"^[0-9a-fA-F]{40}$", ioc):
return "sha1"
if re.match(r"^[0-9a-fA-F]{64}$", ioc):
return "sha256"
if re.match(r"^https?://", ioc):
return "url"
if re.match(r"^[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}$", ioc):
return "domain"
return "unknown"
def geolocate_ip(ip: str) -> dict:
"""Free geolocation via ip-api.com — no key required."""
try:
r = SESSION.get(f"http://ip-api.com/json/{ip}?fields=status,country,city,isp,org,as,query",
timeout=TIMEOUT)
data = r.json()
if data.get("status") == "success":
return {
"country": data.get("country", ""),
"city": data.get("city", ""),
"isp": data.get("isp", ""),
"org": data.get("org", ""),
"asn": data.get("as", "")
}
except Exception:
pass
return {}
def check_tor_exit(ip: str) -> bool:
"""Check if IP is a known Tor exit node via dan.me.uk."""
try:
r = SESSION.get(f"https://check.torproject.org/torbulkexitlist", timeout=TIMEOUT)
return ip in r.text
except Exception:
return False
def lookup_virustotal(ioc: str, ioc_type: str) -> dict:
"""VirusTotal v3 API lookup for IP, domain, hash, or URL."""
if not VT_API_KEY:
return {"error": "VT_API_KEY not set — export VT_API_KEY=your_key"}
headers = {"x-apikey": VT_API_KEY}
endpoint_map = {
"ipv4": f"https://www.virustotal.com/api/v3/ip_addresses/{ioc}",
"domain": f"https://www.virustotal.com/api/v3/domains/{ioc}",
"md5": f"https://www.virustotal.com/api/v3/files/{ioc}",
"sha1": f"https://www.virustotal.com/api/v3/files/{ioc}",
"sha256": f"https://www.virustotal.com/api/v3/files/{ioc}",
}
if ioc_type == "url":
import base64
url_id = base64.urlsafe_b64encode(ioc.encode()).decode().rstrip("=")
endpoint = f"https://www.virustotal.com/api/v3/urls/{url_id}"
elif ioc_type in endpoint_map:
endpoint = endpoint_map[ioc_type]
else:
return {"error": f"Unsupported IOC type: {ioc_type}"}
try:
r = SESSION.get(endpoint, headers=headers, timeout=TIMEOUT)
if r.status_code == 200:
data = r.json().get("data", {}).get("attributes", {})
stats = data.get("last_analysis_stats", {})
malicious = stats.get("malicious", 0)
total = sum(stats.values()) if stats else 0
categories = list(data.get("last_analysis_results", {}).values())
threats = [c.get("result", "") for c in categories if c.get("category") == "malicious" and c.get("result")][:5]
return {
"malicious": malicious,
"total": total,
"threats": list(set(threats))[:3],
"reputation": data.get("reputation", 0)
}
elif r.status_code == 404:
return {"malicious": 0, "total": 0, "threats": [], "not_found": True}
else:
return {"error": f"HTTP {r.status_code}"}
except Exception as e:
return {"error": str(e)}
def lookup_abuseipdb(ip: str) -> dict:
"""AbuseIPDB v2 — IP reputation and abuse reports."""
if not ABUSEIPDB_KEY:
return {"error": "ABUSEIPDB_KEY not set — export ABUSEIPDB_KEY=your_key"}
try:
r = SESSION.get("https://api.abuseipdb.com/api/v2/check",
headers={"Key": ABUSEIPDB_KEY, "Accept": "application/json"},
params={"ipAddress": ip, "maxAgeInDays": 90, "verbose": True},
timeout=TIMEOUT)
if r.status_code == 200:
d = r.json().get("data", {})
return {
"abuse_score": d.get("abuseConfidenceScore", 0),
"total_reports": d.get("totalReports", 0),
"last_reported": d.get("lastReportedAt", ""),
"isp": d.get("isp", ""),
"usage_type": d.get("usageType", ""),
"is_tor": d.get("isTor", False)
}
except Exception as e:
return {"error": str(e)}
return {}
def lookup_malwarebazaar(hash_val: str) -> dict:
"""MalwareBazaar hash lookup — free, no key required."""
try:
r = SESSION.post("https://mb-api.abuse.ch/api/v1/",
data={"query": "get_info", "hash": hash_val},
timeout=TIMEOUT)
if r.status_code == 200:
data = r.json()
if data.get("query_status") == "hash_not_found":
return {"found": False}
samples = data.get("data", [])
if samples:
s = samples[0]
return {
"found": True,
"file_name": s.get("file_name", ""),
"file_type": s.get("file_type", ""),
"file_size": s.get("file_size", 0),
"first_seen": s.get("first_seen", ""),
"tags": s.get("tags", []),
"signature": s.get("signature", ""),
"vendor_intel": s.get("vendor_intel", {})
}
except Exception as e:
return {"error": str(e)}
return {"found": False}
def determine_verdict(vt: dict, abuse: dict, malware: dict, ioc_type: str) -> tuple:
"""Return (verdict, reason) based on all source scores."""
if malware.get("found"):
return "MALICIOUS", f"MalwareBazaar: {malware.get('signature','known malware')}"
vt_mal = vt.get("malicious", 0)
vt_total = vt.get("total", 1)
abuse_score = abuse.get("abuse_score", 0)
if vt_mal >= 5 or abuse_score >= 50:
return "MALICIOUS", f"VT: {vt_mal}/{vt_total} | AbuseIPDB: {abuse_score}/100"
if vt_mal >= 2 or abuse_score >= 20:
return "SUSPICIOUS", f"VT: {vt_mal}/{vt_total} | AbuseIPDB: {abuse_score}/100"
if vt_total == 0 and not abuse:
return "UNKNOWN", "No data from any source"
return "CLEAN", f"VT: {vt_mal}/{vt_total} | AbuseIPDB: {abuse_score}/100"
def lookup_ioc(ioc: str, idx: int) -> dict:
ioc_type = classify_ioc(ioc)
result = {"ioc": ioc, "type": ioc_type, "index": idx}
print(f"\nIOC #{idx}: {ioc} ({ioc_type.upper()})")
if ioc_type == "ipv4":
geo = geolocate_ip(ioc)
result["geo"] = geo
if geo:
print(f" Location: {geo.get('country','')} / {geo.get('city','')} / {geo.get('asn','')}")
is_tor = check_tor_exit(ioc)
if is_tor:
print(f" ⚠ TOR EXIT NODE — source anonymization, high risk")
result["is_tor"] = True
vt = lookup_virustotal(ioc, ioc_type)
result["virustotal"] = vt
if "error" not in vt and not vt.get("not_found"):
threats_str = ", ".join(vt.get("threats", []))
print(f" VirusTotal: {vt.get('malicious',0)}/{vt.get('total',0)} flagged as MALICIOUS")
if threats_str:
print(f" Categories: {threats_str}")
elif vt.get("not_found"):
print(f" VirusTotal: Not found (new IOC)")
elif "error" in vt:
print(f" VirusTotal: {vt['error']}")
abuse = {}
if ioc_type == "ipv4":
abuse = lookup_abuseipdb(ioc)
result["abuseipdb"] = abuse
if "error" not in abuse:
print(f" AbuseIPDB: Score {abuse.get('abuse_score',0)}/100 | {abuse.get('total_reports',0)} reports")
if abuse.get("last_reported"):
print(f" Last reported: {abuse['last_reported'][:10]}")
malware = {}
if ioc_type in ("md5", "sha1", "sha256"):
malware = lookup_malwarebazaar(ioc)
result["malwarebazaar"] = malware
if malware.get("found"):
print(f" MalwareBazaar: FOUND — {malware.get('signature','unknown')} | {malware.get('file_name','')}")
if malware.get("tags"):
print(f" Tags: {', '.join(malware['tags'][:5])}")
else:
print(f" MalwareBazaar: Not found")
verdict, reason = determine_verdict(vt, abuse, malware, ioc_type)
result["verdict"] = verdict
result["verdict_reason"] = reason
verdict_icons = {"MALICIOUS": "✗ MALICIOUS", "SUSPICIOUS": "⚠ SUSPICIOUS",
"CLEAN": "✓ CLEAN", "UNKNOWN": "? UNKNOWN"}
print(f" Verdict: {verdict_icons.get(verdict, verdict)} — {reason}")
if verdict == "MALICIOUS":
if ioc_type == "ipv4":
print(f" Action: Block immediately at perimeter firewall")
elif ioc_type in ("md5","sha1","sha256"):
print(f" Action: Quarantine file. Do not execute. Hunt for presence on all endpoints.")
time.sleep(0.5) # API rate limit courtesy
return result
def main():
parser = argparse.ArgumentParser(description="Threat Intelligence Lookup — Multi-source IOC enrichment")
parser.add_argument("--ioc", nargs="+", required=False, help="One or more IOCs (IP, domain, hash, URL)")
parser.add_argument("--file", type=str, required=False, help="File with one IOC per line")
parser.add_argument("--output", type=str, default=None, help="JSON output file")
args = parser.parse_args()
iocs = list(args.ioc or [])
if args.file:
try:
with open(args.file) as fh:
iocs += [line.strip() for line in fh if line.strip() and not line.startswith("#")]
except FileNotFoundError:
print(f"[!] File not found: {args.file}")
sys.exit(1)
if not iocs:
parser.print_help()
sys.exit(1)
print(f"[*] Threat Intel Lookup | {len(iocs)} IOC(s) | Sources: VirusTotal, AbuseIPDB, MalwareBazaar, ip-api")
print("─" * 60)
all_results = []
for i, ioc in enumerate(iocs, 1):
try:
r = lookup_ioc(ioc, i)
all_results.append(r)
except Exception as e:
print(f"[!] Error on {ioc}: {e}")
malicious = sum(1 for r in all_results if r.get("verdict") == "MALICIOUS")
suspicious = sum(1 for r in all_results if r.get("verdict") == "SUSPICIOUS")
clean = sum(1 for r in all_results if r.get("verdict") == "CLEAN")
print(f"\n[*] Summary: {malicious} MALICIOUS | {suspicious} SUSPICIOUS | {clean} CLEAN | {len(iocs)-malicious-suspicious-clean} UNKNOWN")
output = args.output or f"threat_intel_{datetime.now().strftime('%Y-%m-%d')}.json"
with open(output, "w") as fh:
json.dump({"scan_date": datetime.now().isoformat(), "ioc_count": len(iocs),
"results": all_results}, fh, indent=2)
print(f"[*] Results saved: {output}")
return 2 if malicious else (1 if suspicious else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--ioclist[str]One or more IOCs: IPv4 address, domain, MD5/SHA1/SHA256 hash, or URL.
--filestrPath to file with one IOC per line.
--outputstrJSON output file. Default: threat_intel_<date>.json.
What This Solves
You have an indicator - an IP from a firewall log, a domain from a phishing email, a file hash from an endpoint alert - and you need to know whether it is actually bad before you act on it. Doing that by hand means three browser tabs, three logins, three different verdict formats, and no record of what you looked up. For one indicator that is annoying. For the forty in an incident, it is the reason the triage takes all afternoon. This queries the reputation services for each indicator and returns one consolidated verdict per IOC, in a format you can keep: VirusTotal detection ratio across engines AbuseIPDB abuse confidence score and report history Shodan what is exposed on that IP, if you have a key It accepts IPv4 addresses, domains, and MD5/SHA1/SHA256 hashes, works on a list from a file, and writes JSON - so the lookup becomes evidence rather than something you did in a tab and closed. Pairs with `ioc-extractor`, which pulls the indicators out of logs and emails in the first place.
Is It Safe to Run
Safe for your network, with one thing to understand first: **Every indicator you look up is sent to a third party.** That is not a flaw - it is how a reputation service works - but it has two consequences worth deciding about before an incident, not during one: You are disclosing what you are investigating. Submitting an indicator to VirusTotal tells VirusTotal, and anyone with access to its telemetry, that someone is interested in it. During a targeted intrusion, an attacker watching their own infrastructure can learn that they have been detected. Mature IR teams treat lookups of attacker infrastructure as a disclosure decision. Internal indicators should not be submitted at all. An internal hostname, an RFC 1918 address, a hash of your own proprietary binary - none of those belong in a public reputation service. Check what is in your list before you feed it in. Beyond that it is harmless: read-only queries, nothing is uploaded except the indicator itself, no files are submitted, and nothing on your network is touched. On your own machine it writes the JSON report under --output.
Before You Start
1. Python 3.9 or newer: python --version 2. The library: pip install "requests>=2.28" 3. At least one API key. All three services are free to sign up for at the tier this needs. See API Keys. 4. Know your rate limits. The free VirusTotal tier is roughly 4 lookups per minute - a list of 200 indicators will take an hour, or will start failing. That is a property of the service, not of this script.
API Keys
From the environment. Never from an argument - an argument is visible to every user on the machine in `ps auxww` and lands in your shell history, and an API key is a credential. VT_API_KEY VirusTotal v3. The most useful single key. ABUSEIPDB_KEY AbuseIPDB v2. IP reputation and report history. SHODAN_KEY Shodan. Optional - exposure data for IPs. Linux or macOS: export VT_API_KEY='your-key' export ABUSEIPDB_KEY='your-key' export SHODAN_KEY='your-key' Windows PowerShell: $env:VT_API_KEY = 'your-key' $env:ABUSEIPDB_KEY = 'your-key' Windows Command Prompt: set VT_API_KEY=your-key set ABUSEIPDB_KEY=your-key A missing key is not fatal. The script reports `VT_API_KEY not set` for that source and continues with the others - so one key is enough to start, and a partial result is clearly labelled as partial rather than quietly thinner.
Your First Run
Start with one indicator you already know the answer to - a known-bad test domain, or an address from a blocked connection: export VT_API_KEY='your-key' python threat_intel_lookup.py --ioc 198.51.100.23 Several at once: python threat_intel_lookup.py --ioc 198.51.100.23 evil.example.com \ 44d88612fea8a8f36de82e1278abb02f Or from a file, one per line - which is how this is normally used, with the file produced by `ioc-extractor`: python threat_intel_lookup.py --file iocs.txt \ --output triage-$(date +%F).json Keep the JSON. During an incident, "we checked that IP at 14:02 and it was clean" is a thing you will need to say precisely.
What the Output Means
Per indicator, the verdict from each source that answered. Reading it, and the three traps: A VirusTotal detection ratio is not a vote. `3/89` does not mean 3% bad. It means three engines flagged it, and those three may be the ones that are right, or may be three engines that flag every new domain. **Look at which engines** - a detection from several major vendors is worth far more than a higher count from obscure ones. Zero detections is not "clean". It is "not known to be bad". Freshly registered attacker infrastructure is unknown to everyone for the first hours or days, which is exactly when it is used. A clean verdict on a domain registered yesterday is itself suspicious - check the registration date, not just the score. An AbuseIPDB score reflects reports, not truth. A high confidence score means many people reported it. A shared hosting address or a large NAT gateway accumulates reports because of what else lives behind it. Read the report categories, not only the number. Shodan tells you exposure, not reputation. What is listening on that address. Useful context - an IP running an exposed RDP and an old OpenSSH is a different risk profile from one running nothing - but it is not a verdict. The honest summary: this collapses three lookups into one and gives you a record. **It does not decide for you.** The verdict line is a starting point for a judgement, and an indicator with a clean verdict and suspicious behaviour in your own logs is still suspicious.
Exit Codes for Cron and CI
0 nothing flagged 1 suspicious indicators 2 malicious indicators 130 you pressed Ctrl-C The codes make it usable inside an automated triage step - feed a log's extracted indicators in and gate on 2. Be careful about automating any blocking action off this, for the reasons in the previous section: a shared hosting IP with a high AbuseIPDB score may be hosting both the attacker and a service your business needs.
Every Flag
--ioc LIST One or more indicators: IPv4 addresses, domains, or MD5/SHA1/SHA256 hashes. Mixed types in one run are fine - each is routed to the sources that can answer for it. --file PATH A file with one indicator per line. The usual way to use this, paired with `ioc-extractor`. --output PATH JSON report. Default threat_intel_<date>.json. Give it `--ioc` or `--file`. Keys come from the environment - see API KEYS.
When Something Fails
"VT_API_KEY not set — export VT_API_KEY=your_key" That source is skipped and the others still run. Set the key, or accept the partial result knowingly. Lookups start failing partway through a list Rate limiting. The free VirusTotal tier is about 4 requests per minute. Split the list, wait, or use a paid tier. The script cannot work around a service's quota. ModuleNotFoundError: requests `pip install "requests>=2.28"`. An indicator comes back with nothing from any source Either all three keys are unset, or the indicator type is not one the sources cover. Hashes are not in AbuseIPDB; domains are not in Shodan. A clean verdict on something you are sure is malicious Likely new. Reputation services know what has been reported, and fresh infrastructure is unknown by design. Check the domain's registration date and the certificate's issue date - both are often more diagnostic than a reputation score in the first 48 hours. A malicious verdict on something you depend on Check whether the finding is about the address or about a neighbour. Shared hosting and CDN addresses collect reports from everything else behind them. Resolve the hostname and check what the reports actually describe before blocking anything. You are investigating a targeted intrusion Consider whether to look up the attacker's infrastructure at all. A submission is visible to the service and, through its telemetry, potentially to others. Many IR teams use passive sources for this phase specifically to avoid tipping off the intruder.
Automation Scripts
/scripts/subnet-scanner/scripts/web-app-scanner/scripts/host-vulnerability-scanner/scripts/ssl-cert-checkerTags
threat-inteliocvirustotalabuseipdbmalwaresocincident-responsehash-lookupip-reputation