Build with the API / Sector ransomware watch for Slack

Sector ransomware watch for Slack

New dark-web ransomware victims for a sector/country, as a Slack message.

What you get

Lists leak-site victims posted in the last N days (free tier: up to 7), optionally filtered to one sector and/or country, and formats them as a Slack mrkdwn message with the week's most active groups underneath. If SLACK_WEBHOOK_URL is set the message is posted; otherwise it is printed so you can see exactly what would go out.

Endpoints
/darkweb/ransomware/victims /darkweb/ransomware/victims/facets
Credits per run
2
Key
Works on the free key

The code

sector-ransomware-watch.py, 133 lines. Python 3, needs requests.

#!/usr/bin/env python3
"""sector-ransomware-watch: new dark-web ransomware victims for a sector/country, as a Slack message.

What it does
  Lists leak-site victims posted in the last N days (free tier: up to 7), optionally
  filtered to one sector and/or country, and formats them as a Slack mrkdwn message
  with the week's most active groups underneath. If SLACK_WEBHOOK_URL is set the
  message is posted; otherwise it is printed so you can see exactly what would go out.

Endpoints
  GET /darkweb/ransomware/victims/facets   (1 credit)  sector/country/group counts
  GET /darkweb/ransomware/victims          (1 credit)  the victim rows

Cost: 2 credits per run. Works on the free tier (7-day window, 25 rows per list).

Usage
  python3 sector-ransomware-watch.py --sector Healthcare
  python3 sector-ransomware-watch.py --sector "Financial Services" --country US --days 3
  Sector names are the values returned by the facets endpoint (printed on a bad name).
"""
import argparse
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))


def main():
    ap = argparse.ArgumentParser(description="New ransomware victims for a sector/country -> Slack message")
    ap.add_argument("--sector", help="sector name exactly as the facets endpoint returns it")
    ap.add_argument("--country", help="ISO-2 country code, e.g. US, GB, DE")
    ap.add_argument("--days", type=int, default=7, help="lookback in days (free tier caps at 7)")
    ap.add_argument("--limit", type=int, default=25, help="max victims to list (free tier caps at 25)")
    args = ap.parse_args()

    facets = api_get("/darkweb/ransomware/victims/facets", days=args.days).json()
    sectors = {s["value"]: s["count"] for s in facets.get("sectors", [])}
    if args.sector and args.sector not in sectors:
        sys.exit("Unknown sector %r. Sectors seen in the window: %s" % (args.sector, ", ".join(sorted(sectors))))

    time.sleep(PACE_SECONDS)
    filters = {}
    if args.sector:
        filters["sector"] = args.sector
    if args.country:
        filters["country"] = args.country.upper()
    data = api_get("/darkweb/ransomware/victims", days=args.days, limit=args.limit, **filters).json()
    victims = data.get("victims", [])

    scope = " / ".join(x for x in [args.sector, args.country and args.country.upper()] if x) or "all sectors"
    lines = ["*ThreatCluster ransomware watch: %s, last %d days*" % (scope, data.get("lookback_days") or args.days)]
    if not victims:
        lines.append("No new leak-site victims matched.")
    else:
        shown = "%d new leak-site victims" % len(victims)
        if len(victims) >= args.limit:
            shown += " (showing the first %d)" % args.limit
        lines.append(shown)
        for v in victims:
            posted = (v.get("discovered") or "")[:10]
            name = v.get("name") or v.get("victim") or "?"
            where = v.get("country") or "--"
            sector = "" if args.sector else "  [%s]" % (v.get("sector") or "unknown sector")
            lines.append("• %s  %s  →  <%s/dark-web/victim/%s|%s>  (%s)%s"
                         % (posted, v.get("group", "?"), SITE, v.get("id", ""), name, where, sector))

    top_groups = ", ".join("%s %d" % (g["value"], g["count"]) for g in facets.get("groups", [])[:5])
    top_sectors = ", ".join("%s %d" % (s["value"], s["count"]) for s in facets.get("sectors", [])[:5])
    lines.append("_Most active groups this window: %s_" % top_groups)
    lines.append("_Most hit sectors this window: %s_" % top_sectors)
    lines.append("Source: %s/dark-web/victims" % SITE)
    message = "\n".join(lines)

    webhook = os.environ.get("SLACK_WEBHOOK_URL")
    if webhook:
        resp = requests.post(webhook, json={"text": message}, timeout=30)
        if resp.status_code >= 400:
            sys.exit("Slack webhook returned HTTP %d: %s" % (resp.status_code, resp.text[:200]))
        print("Posted %d victims to Slack." % len(victims))
    else:
        print(message)
    report_credits()


if __name__ == "__main__":
    main()

The shell one-liner, sector-ransomware-watch.sh:

#!/usr/bin/env bash
# sector-ransomware-watch.sh: leak-site victims for a sector (arg 1, optional) in the last 7 days. 1 credit. Free tier OK.
# usage: ./sector-ransomware-watch.sh Healthcare
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'*}"; }
SECTOR="${1:-}"
tc_get /darkweb/ransomware/victims --data-urlencode days=7 --data-urlencode limit=25 --data-urlencode "sector=$SECTOR" \
 | jq -r --arg site "$SITE" '"ThreatCluster ransomware watch: \(.count) victims", (.victims[] | "• \(.discovered[:10])  \(.group)  →  \(.name)  (\(.country // "--"))  \($site)/dark-web/victim/\(.id)")'

Output

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

[threatcluster] credits used this run: 2, remaining today: 97
*ThreatCluster ransomware watch: Healthcare, last 7 days*
25 new leak-site victims (showing the first 25)
• 2026-09-03  settra  →  <https://threatcluster.io/dark-web/victim/60e0a7362481f352|int.diasorin.com>  (IT)
• 2026-09-03  settra  →  <https://threatcluster.io/dark-web/victim/85a7b364e8c30823|medevolve.com>  (US)
• 2026-09-03  settra  →  <https://threatcluster.io/dark-web/victim/ecd2cfa1607e6d88|neolife.com>  (US)
• 2026-09-03  storm  →  <https://threatcluster.io/dark-web/victim/c940e69fe18a26df|SITES Medical>  (US)
• 2026-09-02  incransom  →  <https://threatcluster.io/dark-web/victim/8fcff03fdbfe25d0|Policlinico Triestino>  (IT)
• 2026-09-02  payoutsking  →  <https://threatcluster.io/dark-web/victim/8cd7ef9f7109f3b3|Proliance Surgeons>  (US)
• 2026-09-01  thegentlemen  →  <https://threatcluster.io/dark-web/victim/dff02778ccad0dc8|Nutex Health>  (US)
• 2026-09-01  krybit  →  <https://threatcluster.io/dark-web/victim/f471acfa69878ee8|seashellhospital.com>  (IN)
• 2026-08-31  braincipher  →  <https://threatcluster.io/dark-web/victim/135e5b7846f65462|ccsperfusion.com>  (US)
• 2026-08-31  incransom  →  <https://threatcluster.io/dark-web/victim/a035a0f486a4da6b|New Century Ophthalmology Group>  (US)
• 2026-08-31  insomnia  →  <https://threatcluster.io/dark-web/victim/08151cba63805baf|Metro Tulsa Foot>  (US)
• 2026-08-31  wallstreet  →  <https://threatcluster.io/dark-web/victim/5e2f5aaa38063d47|Cedar County Memorial Hospital>  (US)
• 2026-08-30  direwolf  →  <https://threatcluster.io/dark-web/victim/f827b27c8ac177f9|Erdem Hospital>  (TR)
• 2026-08-30  direwolf  →  <https://threatcluster.io/dark-web/victim/f5af021f3dad9da2|Hospital Clnico Universidad de Chile>  (CL)
• 2026-08-30  falcon  →  <https://threatcluster.io/dark-web/victim/34d3a761a1383ae6|Globus Medical>  (US)
• 2026-08-30  qilin  →  <https://threatcluster.io/dark-web/victim/a46070661b084216|Crystalpharmatech>  (--)
• 2026-08-30  thegentlemen  →  <https://threatcluster.io/dark-web/victim/c84ad630dbd10feb|Exacta Optech Labcenter>  (BR)
• 2026-08-29  qilin  →  <https://threatcluster.io/dark-web/victim/6e4927ba5de567b6|CareClinics>  (MY)
• 2026-08-28  shinyhunters  →  <https://threatcluster.io/dark-web/victim/b1761241d32a7d39|McKesson Corporation>  (US)
• 2026-08-28  shinyhunters  →  <https://threatcluster.io/dark-web/victim/eea9d5570fb5a8f9|Elekta AB>  (SE)
• 2026-08-28  rhysida  →  <https://threatcluster.io/dark-web/victim/9f5ef6e2a6c5e5c7|Valley Health Team>  (--)
• 2026-08-28  global  →  <https://threatcluster.io/dark-web/victim/6fff16c8fc8794ac|Hangzhou Qihan Biotech Co., Ltd.>  (CN)
• 2026-08-28  moneymessage  →  <https://threatcluster.io/dark-web/victim/a951b49148ededb8|ProCare>  (US)
• 2026-08-28  lockbit5  →  <https://threatcluster.io/dark-web/victim/c710acc49c839ad0|tnmed.org>  (TN)
• 2026-08-28  anubis  →  <https://threatcluster.io/dark-web/victim/52c2ba03868a43be|Caduceus Medical Group>  (--)
_Most active groups this window: qilin 27, thegentlemen 23, zawoo 19, incransom 17, settra 16_
_Most hit sectors this window: Manufacturing 41, Technology 37, Not Found 30, Professional Services 28, Healthcare 26_
Source: https://threatcluster.io/dark-web/victims

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 sector-ransomware-watch.py

The file on GitHub: sector-ransomware-watch.py and sector-ransomware-watch.sh.

Goes well with

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