Build with the API / SIEM indicator enrichment
SIEM indicator enrichment
What does ThreatCluster know about this indicator?
What you get
For each IOC (domain, IPv4 or MD5/SHA1/SHA256 hash) it finds the matching entity, then prints the incident context: the clusters it appears in (title, score, link), the actors / malware / tools seen alongside it, and the other indicators from the top cluster, so an analyst can pivot straight from a SIEM alert.
- Endpoints
/entities/search/entities/{type}/{value}/threats/{slug}/iocs- Credits per run
- 6
- Key
- Works on the free key
Step by step
1. Get a key
Sign in, open Settings, then API & Feeds, and select Generate API key. The key is shown once, so copy it. Every account has one and the free key covers this recipe.

The API page. The key itself is created in Settings, under API & Feeds. 2. Download the script and set the key
The script is one file with one dependency. It reads the key from the environment, so the key never goes into the file.
curl -sO https://raw.githubusercontent.com/Jam0k/Cyber-Threat-Intelligence-API/main/examples/recipes/siem-ioc-enrichment.py pip install requests export THREATCLUSTER_API_KEY=tc_live_your_key_here3. Run it
Pass any mix of domains, IPv4 addresses and file hashes. For each one the script prints the incidents it appears in, the actors and malware named alongside it, and the other validated indicators from the same incident.
python3 siem-ioc-enrichment.py aar.gandhibludtric.com 172.94.9.194 \ 9cb68f986043a576e19d32184c583b7d8f571c7219d8dc0065dced1c13f077ef
Run on 29 September 2026. The domain ties to an incident naming Salt Typhoon. The IP address brings six related addresses with it. 4. Open the incident
Each indicator links to its incident, which has the summary, the source articles and the full entity list.

The incident behind the IP address in the run above. 5. Call it from your SIEM
Most SIEMs can run a script on an alert and attach its output. Pass the indicator from the alert as the argument. Each indicator costs up to 3 credits.
python3 siem-ioc-enrichment.py "$ALERT_INDICATOR" > enrichment.txt
The code
siem-ioc-enrichment.py, 148 lines. Python 3, needs requests.
#!/usr/bin/env python3
"""siem-ioc-enrichment: what does ThreatCluster know about this indicator?
What it does
For each IOC (domain, IPv4 or MD5/SHA1/SHA256 hash) it finds the matching entity, then
prints the incident context: the clusters it appears in (title, score, link), the actors
/ malware / tools seen alongside it, and the other indicators from the top cluster, so an
analyst can pivot straight from a SIEM alert.
Endpoints
GET /entities/search?q=<ioc> (1 credit) resolve the indicator to an entity
GET /entities/{type}/{value} (1 credit) clusters + co-occurring entities
GET /threats/{slug}/iocs (1 credit) validated indicators of the top cluster
Cost: up to 3 credits per IOC. Works on the free tier (7-day window).
Usage
python3 siem-ioc-enrichment.py evil-domain.example 203.0.113.7 <sha256>
"""
import argparse
import re
import json
import os
import sys
import time
import requests
API_BASE = os.environ.get("THREATCLUSTER_API_BASE", "https://threatcluster.io/api/public/v1").rstrip("/")
API_KEY = os.environ.get("THREATCLUSTER_API_KEY", "")
SITE = os.environ.get("THREATCLUSTER_SITE", "https://threatcluster.io") # where the linked pages live
PACE_SECONDS = 2.1 # free keys get 30 requests/min; ~28/min keeps loops clear of 429s
_credits = {"used": 0, "remaining": None}
def _auth_headers():
# A tc_... key is sent as X-API-Key; anything else is a short-lived bearer (e.g. from `tc login`).
if not API_KEY:
sys.exit("THREATCLUSTER_API_KEY is not set. Free keys: https://threatcluster.io/about/api")
if API_KEY.startswith("tc_"):
return {"X-API-Key": API_KEY}
return {"Authorization": "Bearer " + API_KEY}
def api_get(path, _allow=(), **params):
"""GET one endpoint. Exits non-zero with the API's own error body, unless the
status is listed in _allow (per-item lookups where a 404 is itself an answer)."""
r = requests.get(API_BASE + path, headers=_auth_headers(), params=params, timeout=60)
if "X-Request-Cost" in r.headers:
_credits["used"] += int(r.headers["X-Request-Cost"])
if "X-RateLimit-Remaining" in r.headers:
_credits["remaining"] = r.headers["X-RateLimit-Remaining"]
if r.status_code >= 400 and r.status_code not in _allow:
try:
body = json.dumps(r.json())
except ValueError:
body = r.text[:500]
sys.exit("HTTP %d from %s: %s" % (r.status_code, path, body))
return r
def cluster_url(cluster):
# The free payload carries `slug` (canonical page) and `cluster_id`; the last 8 hex
# chars of the id also resolve, via a 301 to the slug, when slug is missing.
ident = cluster.get("slug") or cluster.get("short_id") or cluster.get("cluster_id", "").replace("-", "")[-8:]
return "%s/cluster/%s" % (SITE, ident)
def report_credits():
# Stderr, so it never pollutes piped output (brief.md, blocklists, JSON).
# Keys without a daily cap send no remaining-credits header.
left = ", remaining today: %s" % _credits["remaining"] if _credits["remaining"] is not None else ""
sys.stderr.write("[threatcluster] credits used this run: %d%s\n" % (_credits["used"], left))
INDICATOR_TYPES = ("domain", "ipv4", "ipv6", "url", "md5", "sha1", "sha256", "email")
CONTEXT_TYPES = ("apt_group", "ransomware_group", "malware", "tool", "campaign", "cve", "attack_type", "platform")
def guess_type(ioc):
# Only used to narrow the search; the API's own entity_type is what we trust.
if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", ioc):
return "ipv4"
if re.fullmatch(r"[0-9a-fA-F]{64}", ioc):
return "sha256"
if re.fullmatch(r"[0-9a-fA-F]{40}", ioc):
return "sha1"
if re.fullmatch(r"[0-9a-fA-F]{32}", ioc):
return "md5"
return "domain"
def enrich(ioc):
hits = api_get("/entities/search", q=ioc, entity_type=guess_type(ioc), limit=5).json().get("entities", [])
match = next((e for e in hits if (e.get("entity_value") or "").lower() == ioc.lower()), None)
print("== %s" % ioc)
if not match:
print(" no ThreatCluster context in the last 7 days\n")
return
etype, value = match["entity_type"], match["entity_value"]
print(" type %s; %s cluster(s), %s article(s); page %s/entities/%s/%s"
% (etype, match.get("cluster_count", "?"), match.get("article_count", "?"), SITE, etype.replace("_", "-"), value))
time.sleep(PACE_SECONDS)
detail = api_get("/entities/%s/%s" % (etype, value)).json()
clusters = detail.get("clusters", [])
print(" Clusters:")
for c in clusters:
print(" %5.1f %-7s %s\n %s" % (c.get("threat_score") or 0, c.get("urgency_level") or "-",
c.get("ai_title") or c.get("title"), cluster_url(c)))
co = detail.get("co_entities") or {}
for t in CONTEXT_TYPES:
vals = co.get(t)
if vals:
print(" %-17s %s" % (t + ":", ", ".join(str(v.get("value", v)) if isinstance(v, dict) else str(v) for v in vals[:8])))
if not clusters:
print()
return
time.sleep(PACE_SECONDS)
# /iocs returns validated indicators only. The `entities` block on the
# cluster itself lists every hostname the article mentions, including the
# publisher's and the victim's, so it is not a list of indicators.
top = api_get("/threats/%s/iocs" % (clusters[0].get("slug") or clusters[0].get("cluster_id"))).json()
print(" Related indicators (validated, from the top cluster):")
shown = 0
for t in INDICATOR_TYPES:
for row in top.get("iocs") or []:
if row.get("type") == t and str(row.get("value")).lower() != ioc.lower():
print(" %-8s %-44s %s" % (t, row.get("value"), row.get("confidence") or ""))
shown += 1
if not shown:
print(" (none beyond the queried indicator)")
print()
def main():
ap = argparse.ArgumentParser(description="Enrich IOCs with ThreatCluster incident context")
ap.add_argument("iocs", nargs="+", help="domains, IPv4s or file hashes")
args = ap.parse_args()
for i, ioc in enumerate(args.iocs):
if i:
time.sleep(PACE_SECONDS)
enrich(ioc.strip())
report_credits()
if __name__ == "__main__":
main()
Output
From a run on 29 September 2026. Yours will differ as the corpus moves.
== aar.gandhibludtric.com
type domain; 1 cluster(s), 1 article(s); page https://threatcluster.io/entities/domain/aar.gandhibludtric.com
Clusters:
82.2 - Critical Citrix NetScaler Zero-Day Vulnerabilities Exploited
https://threatcluster.io/cluster/critical-citrix-netscaler-zero-day-exploitation-confirmed-4d8f6ff9
apt_group: Earth Estries, GhostEmperor, Salt Typhoon, Unc2286
malware: Deed RAT, Snappybee
tool: SoftEther VPN
attack_type: Malware
platform: Cisco Devices, Citrix NetScaler Gateway
Related indicators (validated, from the top cluster):
(none beyond the queried indicator)
== 172.94.9.194
type ipv4; 1 cluster(s), 1 article(s); page https://threatcluster.io/entities/ipv4/172.94.9.194
Clusters:
65.2 - Multiple Mythic C2 Interfaces Exposed on Port 7443
https://threatcluster.io/cluster/multiple-mythic-c2-interfaces-exposed-on-port-7443-ac68d525
Related indicators (validated, from the top cluster):
ipv4 154.198.49.31 high
ipv4 159.198.75.180 high
ipv4 189.141.53.203 high
ipv4 193.201.185.90 high
ipv4 23.148.228.253 high
ipv4 95.158.131.141 high
== 9cb68f986043a576e19d32184c583b7d8f571c7219d8dc0065dced1c13f077ef
type sha256; 1 cluster(s), 3 article(s); page https://threatcluster.io/entities/sha256/9cb68f986043a576e19d32184c583b7d8f571c7219d8dc0065dced1c13f077ef
Clusters:
63.9 - NeedyMantis Malware Framework Targeting Telecommunications and Government Entities
https://threatcluster.io/cluster/needymantis-malware-discovered-in-targeted-operations-ee4e32b2
malware: NeedyMantis
tool: Impacket, TightVNC, Curl
campaign: Daemon Tools Supply Chain Compromise, Daemon Tools Campaign
attack_type: Malware, Supply Chain Attack
platform: Windows, Microsoft Defender Antivirus, Microsoft Office, Poedit, VIM
Related indicators (validated, from the top cluster):
domain corp.tripswithengine.com high
sha256 c82520eb03c084226be4eafbff46f56dca0aa8804a2a7f23a085a96afe71ef77 high
… The file on GitHub: siem-ioc-enrichment.py.
Goes well with
- IOC blocklist export: Validated malicious domains and IPs as plain blocklists.
- A lookup tool for LLM agents: A tool-calling function `threatcluster_lookup(query)` for LLM agents.
- Brand and domain dark-web monitor: Are my brands or domains showing up on the dark web?
- Splunk integration: scripted inputs and lookup blocklists
All recipes: /build. Endpoint reference: /api/public/v1/docs.