Skip to content

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. 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 ThreatCluster API page with the Get a free key button and the quick start, which begins with creating a key
    The API page. The key itself is created in Settings, under API & Feeds.
  2. 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_here
  3. 3. 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
    A terminal enriching a domain and an IP address: each shows its incident with a threat score and link, the threat actors and malware named with it, and related validated indicators
    Run on 29 September 2026. The domain ties to an incident naming Salt Typhoon. The IP address brings six related addresses with it.
  4. 4. Open the incident

    Each indicator links to its incident, which has the summary, the source articles and the full entity list.

    The ThreatCluster incident page for exposed Mythic C2 interfaces, with a summary, a threat score of 65 and 26 extracted entities
    The incident behind the IP address in the run above.
  5. 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

All recipes: /build. Endpoint reference: /api/public/v1/docs.