Build with the API / Trending threat actors

Trending threat actors

Which threat actors, ransomware crews and malware families are rising this week?

What you get

Prints the trending APT groups, ransomware groups and malware families of the last 7 days: how many clusters mention them, the change versus the previous window, whether they are new to the corpus, and the ThreatCluster entity page for each.

Endpoints
/entities/trending
Credits per run
1
Key
Works on the free key

The code

trending-actors.py, 100 lines. Python 3, needs requests.

#!/usr/bin/env python3
"""trending-actors: which threat actors, ransomware crews and malware families are rising this week?

What it does
  Prints the trending APT groups, ransomware groups and malware families of the last
  7 days: how many clusters mention them, the change versus the previous window, whether
  they are new to the corpus, and the ThreatCluster entity page for each.

Endpoints
  GET /entities/trending?time_filter=7d&limit=10   (1 credit)

Cost: 1 credit per run. Works on the free tier.

Usage
  python3 trending-actors.py
  python3 trending-actors.py --types apt_group malware cve
"""
import argparse
from urllib.parse import quote
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).
    remaining = _credits["remaining"] if _credits["remaining"] is not None else "n/a"
    sys.stderr.write("[threatcluster] credits used this run: %d, remaining today: %s\n" % (_credits["used"], remaining))

DEFAULT_TYPES = ["apt_group", "ransomware_group", "malware"]


def main():
    ap = argparse.ArgumentParser(description="Trending actors/malware this week")
    ap.add_argument("--types", nargs="+", default=DEFAULT_TYPES, help="entity types to show")
    ap.add_argument("--limit", type=int, default=10, help="rows per type (free tier caps at 10)")
    args = ap.parse_args()

    data = api_get("/entities/trending", time_filter="7d", limit=args.limit).json()
    trending = data.get("trending", {})
    for etype in args.types:
        rows = trending.get(etype) or []
        print("== %s (last %s) ==" % (etype, data.get("time_filter", "7d")))
        if not rows:
            print("   nothing trending\n")
            continue
        print("   %-28s %8s %8s  %s" % ("entity", "mentions", "change", "page"))
        for r in rows:
            change = "NEW" if r.get("is_new") else ("%+.0f%%" % r["change"] if r.get("change") is not None else "-")
            # quote(safe='') because entity values can contain '/', '#' or spaces.
            print("   %-28s %8s %8s  %s/entities/%s/%s" % ((r.get("value") or "")[:28], r.get("frequency", "?"), change,
                                                          SITE, etype.replace("_", "-"), quote(str(r.get("value") or ""), safe="")))
        print()
    report_credits()


if __name__ == "__main__":
    main()

The shell one-liner, trending-actors.sh:

#!/usr/bin/env bash
# trending-actors.sh: trending APT groups, ransomware groups and malware of the last 7 days. 1 credit. Free tier OK.
# usage: ./trending-actors.sh
set -euo pipefail
: "${THREATCLUSTER_API_KEY:?set THREATCLUSTER_API_KEY (free keys: https://threatcluster.io/about/api)}"
BASE="${THREATCLUSTER_API_BASE:-https://threatcluster.io/api/public/v1}"
SITE="${THREATCLUSTER_SITE:-https://threatcluster.io}"
# A tc_... key goes in X-API-Key; anything else is a bearer (e.g. from `tc login`).
if [[ "$THREATCLUSTER_API_KEY" == tc_* ]]; then AUTH="X-API-Key: $THREATCLUSTER_API_KEY"; else AUTH="Authorization: Bearer $THREATCLUSTER_API_KEY"; fi
# tc_get <path> [curl -G args...]: prints the body; on HTTP >= 400 prints the API error to stderr and fails.
tc_get() { local path=$1; shift; local out code
  out=$(curl -sS -G -H "$AUTH" -w $'\n%{http_code}' "$BASE$path" "$@"); code=${out##*$'\n'}
  if [ "$code" -ge 400 ]; then echo "HTTP $code from $path: ${out%$'\n'*}" >&2; return 1; fi; printf '%s' "${out%$'\n'*}"; }
tc_get /entities/trending --data-urlencode time_filter=7d --data-urlencode limit=10 \
 | jq -r '.trending | to_entries[] | select(.key == "apt_group" or .key == "ransomware_group" or .key == "malware")
          | "== \(.key) ==", (.value[] | "  \(.value)\t\(.frequency) mentions\t\(if .is_new then "NEW" else "\(.change)%" end)")'

Output

From a run on 3 September 2026. Yours will differ as the corpus moves.

[threatcluster] credits used this run: 1, remaining today: 47
== apt_group (last 7d) ==
   entity                       mentions   change  page
   TeamPCP                             4    +300%  https://threatcluster.io/entities/apt-group/TeamPCP
   ShinyHunters                        5     +67%  https://threatcluster.io/entities/apt-group/ShinyHunters
   Apt28                               4    +100%  https://threatcluster.io/entities/apt-group/Apt28
   Apt29                               5     +25%  https://threatcluster.io/entities/apt-group/Apt29
   MuddyWater                          2    +100%  https://threatcluster.io/entities/apt-group/MuddyWater
   Smoke Sandstorm                     2    +100%  https://threatcluster.io/entities/apt-group/Smoke%20Sandstorm
   Silver Fox                          5      NEW  https://threatcluster.io/entities/apt-group/Silver%20Fox
   Lazarus Group                       4      NEW  https://threatcluster.io/entities/apt-group/Lazarus%20Group
   Sandworm                            3      NEW  https://threatcluster.io/entities/apt-group/Sandworm
   Stardust Chollima                   2      NEW  https://threatcluster.io/entities/apt-group/Stardust%20Chollima

== ransomware_group (last 7d) ==
   entity                       mentions   change  page
   Ryuk                                4    +300%  https://threatcluster.io/entities/ransomware-group/Ryuk
   8Base                               2    +100%  https://threatcluster.io/entities/ransomware-group/8Base
   Rhysida                            10      NEW  https://threatcluster.io/entities/ransomware-group/Rhysida
   Aur0ra                              4      NEW  https://threatcluster.io/entities/ransomware-group/Aur0ra
   Interlock                           4      NEW  https://threatcluster.io/entities/ransomware-group/Interlock
   WannaCry                            4      NEW  https://threatcluster.io/entities/ransomware-group/WannaCry
   The Gentlemen                       3      NEW  https://threatcluster.io/entities/ransomware-group/The%20Gentlemen
   Krybit                              3      NEW  https://threatcluster.io/entities/ransomware-group/Krybit
   Jadepuffer                          3      NEW  https://threatcluster.io/entities/ransomware-group/Jadepuffer
   INC                                 2      NEW  https://threatcluster.io/entities/ransomware-group/INC

== malware (last 7d) ==
   entity                       mentions   change  page
   Vidar                              24    +500%  https://threatcluster.io/entities/malware/Vidar
   LummaC2                            19    +850%  https://threatcluster.io/entities/malware/LummaC2
   Pegasus                             7    +600%  https://threatcluster.io/entities/malware/Pegasus
   AMOS                                8    +167%  https://threatcluster.io/entities/malware/AMOS
   GoCaracal                           3    +200%  https://threatcluster.io/entities/malware/GoCaracal
   Shai-hulud                          3    +200%  https://threatcluster.io/entities/malware/Shai-hulud
   Bandook                             3    +200%  https://threatcluster.io/entities/malware/Bandook
   Predator                            4     +33%  https://threatcluster.io/entities/malware/Predator
   SecTopRAT                           2    +100%  https://threatcluster.io/entities/malware/SecTopRAT
   Hookedge                            2    +100%  https://threatcluster.io/entities/malware/Hookedge

Run it

  1. Get a free key at /api. Every account has one; it covers the endpoints above.
  2. export THREATCLUSTER_API_KEY=… your key.
  3. python3 trending-actors.py

The file on GitHub: trending-actors.py and trending-actors.sh.

Goes well with

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