Indicator Triage (SOC)

From a raw IP or domain to a reconciled verdict, network attribution, co-hosted blast radius, and a paste-ready evidence chain.

Updated July 2026

Indicator Triage (SOC) Documentation

You've got an alert and a clock. These recipes take you from a raw indicator — an IP, a domain — to a reconciled verdict, the co-hosted blast radius, network attribution, and a copy-paste evidence chain, without leaving your terminal and without a black box. Every score returns the feeds and timestamps behind it.

Anonymous access is capped at 2 hops; a free key raises that to 3, which covers most of these recipes. Anchor on a {name: "..."} and you'll get answers in milliseconds even across billions of edges. New here? Start with Getting Started, and keep the Graph Schema and Procedures open.

Run it live: Indicator Investigation — the guided workflow that runs these pivots in the browser, opening with a live result you can rerun on your own indicator.

First 30 seconds: is this thing bad?

Reconciled verdict — one blocking-aware answer

Your SIEM flagged an IP. Before you touch the firewall you need a single answer: block or not, and why. Flat feeds disagree with each other — one list says C2, another never heard of it. The graph reconciles every feed that touched the indicator into one verdict you can act on, with the flags that explain it.

// Triage on the reconciled verdict — prefer verdictScore over raw threatScore
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.verdictScore   AS score,
       ip.verdictLevel   AS level,
       ip.verdictBlocking AS block,
       ip.verdictAdvisory AS advisory,
       ip.isC2, ip.isMalware, ip.isTor, ip.isAnonymizer

Sample output:

[{
  "score": 6.27, "level": "HIGH", "block": true, "advisory": null,
  "ip.isC2": false, "ip.isMalware": false,
  "ip.isTor": true, "ip.isAnonymizer": true
}]

Tip: verdictScore / verdictLevel / verdictBlocking are the reconciled triage signals — prefer them over raw threatScore. The boolean is* flags (isC2, isMalware, isPhishing, isTor, isAnonymizer, isThreat) tell you what kind of bad in one row. If verdictLevel is NONE, no feed flagged it — but no-data is not the same as benign (see the coverage-qualified verdict below).

explain() — the verdict's evidence chain

verdictLevel is the headline; explain() is the paragraph you paste into the ticket. It returns the exact feeds, their weights, the scoring arithmetic, and first/last-seen — an inspectable chain, not a number from nowhere.

// Scored verdict + every contributing feed, with weights and timestamps
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources
LIMIT 1

Sample output:

[{
  "indicator": "185.220.101.1",
  "score": 7.46,
  "level": "HIGH",
  "explanation": "185.220.101.1 is listed in 4 threat feed(s). Score 7.5 (Informational - minimal risk).",
  "factors": [
    "Listed in 4 source(s) with combined weight 2.70",
    "Base score: 2.70 × log₂(4 + 1) = 6.27",
    "Recency boost: ×1.1 (last seen 3 days ago)",
    "Age boost: ×1.08 (on lists for 10 days)",
    "Final score: 6.27 × 1.1 × 1.08 = 7.46"
  ],
  "sources": [
    {"feedId": "dan-tor-exit", "weight": 0.5, "firstSeen": "2026-06-23T01:26:36Z", "lastSeen": "2026-06-29T12:11:13Z"},
    {"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-06-22T19:25:54Z", "lastSeen": "2026-07-02T19:35:16Z"},
    {"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-06-22T19:25:52Z", "lastSeen": "2026-06-29T12:11:02Z"},
    {"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-06-28T11:16:39Z", "lastSeen": "2026-06-28T11:16:39Z"}
  ]
}]

Tip: explain() auto-detects the indicator type — it works on IPs, domains, ASNs (AS13335), and CIDR ranges (185.220.101.0/24). Scores are live reads: the value reflects whatever feeds are loaded right now. explain also exists as an MCP tool if your SOAR is agent-driven — see AI & Agents.

Coverage-qualified verdict — "no-data ≠ benign"

An empty verdict is the trap. A host on a big cloud isn't malicious because the cloud also hosts malware, and a host no feed has ever seen is unknown, not clean. The graph answers identity and danger as separate questions, and gates the danger answer on coverage.

// Whose infrastructure is this, separately from whether it's dangerous
CALL whisper.identify(["github.com"])
YIELD host, canonical_name, host_class, roles, confidence
RETURN host, canonical_name, host_class, roles, confidence
LIMIT 5

Sample output:

[{
  "host": "github.com", "canonical_name": "Github",
  "host_class": "multi_tenant_user_content",
  "roles": ["DNS_OPERATOR", "MAIL_RECEIVER", "ORIGIN_AS"], "confidence": 0.7
}]
// Is it dangerous — qualified by coverage (gate on this, don't trust an empty band)
CALL whisper.assess(["github.com"])
YIELD host, band, coverage, sub_labels, signals
RETURN host, band, coverage, sub_labels, signals
LIMIT 5

Sample output:

[{"host": "github.com", "band": "NONE", "coverage": "known-clean", "sub_labels": []}]

Tip: Read coverage before band. known-clean means feeds actively vouch for it; structural-only means we're inferring from the neighborhood; no-data means we have nothing — escalate, don't dismiss. host_class (multi_tenant_user_content, dedicated, cloud, CDN) tells you whether co-tenancy is even meaningful before you pivot on it.

Network attribution & GeoIP

Trace an IP to its network owner

From an IP to its network owner and physical footprint in a single traversal.

The alert names an IP. Before escalating you need to know who owns it and what network it sits in. With flat tools that's three lookups glued together; here it's one hop chain.

// Full BGP chain: IP -> announced prefix -> ASN -> network name
MATCH (ip:IPV4 {name: "104.16.132.229"})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
      -[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5

Sample output:

[{"ip": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}]

Tip: ANNOUNCED_BY reflects current BGP routing, so you always get the live announcement. ROUTES is undirected — it matches whichever arrow you write. For IPs with no live announcement, BELONGS_TO gives the registered allocation block instead.

Who really operates this netblock?

The WHOIS owner of a prefix is often a registry, not the operator actually running the address space. DELEGATED_TO resolves an IP or prefix to the cloud/SaaS vendor behind it — useful when the WHOIS org is a shell or a reseller.

// Vendor operating the address space (distinct from the WHOIS owner)
MATCH (ip:IPV4 {name: "104.16.132.229"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name, v.name AS vendor
LIMIT 1

Sample output:

[{"ip.name": "104.16.132.229", "vendor": "cloudflare"}]

Tip: If the prefix itself carries the delegation, anchor on the prefix: MATCH (ip:IPV4 {name:"..."})-[:BELONGS_TO]->(p:PREFIX)-[:DELEGATED_TO]->(v:VENDOR). Vendor identity tells you who to send the abuse report to, which the WHOIS contact often won't.

Look up GeoIP location

You need the physical location for a geo-restriction check or an incident report.

// GeoIP city and country for an IP
MATCH (ip:IPV4 {name: "109.111.100.154"})
      -[:LOCATED_IN]->(city:CITY)
      -[:HAS_COUNTRY]->(co:COUNTRY)
RETURN DISTINCT ip.name, city.name AS city, co.name AS country
LIMIT 1

Sample output:

[{"ip.name": "109.111.100.154", "city": "Andorra la Vella, AD", "country": "AD"}]

Tip: Anycast IPs often return no city-level GeoIP because they're served from many locations at once. For those, fall back to the BGP chain below.

Country via BGP when GeoIP is empty

When LOCATED_IN returns nothing, get the country from the announcing network's allocation.

// Country via BGP prefix allocation
MATCH (ip:IPV4 {name: "8.8.8.8"})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:HAS_COUNTRY]->(co:COUNTRY)
RETURN ip.name, ap.name AS prefix, co.name AS country
LIMIT 1

Sample output:

[{"ip.name": "8.8.8.8", "prefix": "8.8.8.0/24", "country": "US"}]

Tip: This works even when GeoIP is empty. The country reflects where the announcing network is registered, not the physical server.

Co-hosted infrastructure & blast radius

Reverse DNS: what else is hosted here?

You have an IP from an alert and want to know what else lives on it. RESOLVES_TO is HOSTNAME → IP, so reverse it.

// All domains currently resolving to this IP
MATCH (ip:IPV4 {name: "104.16.132.229"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name LIMIT 20

Sample output:

[
  {"h.name": "menuchin.app"},
  {"h.name": "www.menuchin.app"},
  {"h.name": "qapy.com.ar"},
  {"h.name": "c-cloudflare-com.4i.am"}
]

Tip: Shared hosting is normal for CDN IPs — one Cloudflare IP can front thousands of domains. Count first (next recipe) before you treat co-tenancy as attribution.

Count co-hosted domains before pivoting

// How many domains share this IP?
MATCH (ip:IPV4 {name: "104.16.132.229"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN count(h) AS cohosted
LIMIT 1

Sample output:

[{"cohosted": 1488}]

Tip: A count over a few hundred usually means shared CDN or hosting infrastructure — co-tenancy there is noise. A count under 20 is the interesting case: those domains are likely run by the same operator, worth pivoting through explain() one by one.

Neighborhood toxicity — threat density per prefix

You want to know how many threat-listed IPs share a network prefix with the one you're investigating — a fast read on whether you've stepped into a bad neighborhood. The precomputed threatNeighborCount does it in a single hop, even on hyperscaler blocks.

// Toxic neighbor count for an IP's registered prefix
MATCH (ip:IPV4 {name: "45.148.10.35"})-[:BELONGS_TO]->(p:PREFIX)
RETURN p.name AS prefix, p.threatNeighborCount AS toxic_neighbors
LIMIT 1

Sample output:

[{"prefix": "45.148.10.0/24", "toxic_neighbors": 139}]

The same counter lives on the live announcement — anchor through ANNOUNCED_BY when you want the routed block instead of the registered allocation:

// Same read against the announced (routed) prefix
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
RETURN ap.name AS prefix, ap.threatNeighborCount AS toxic_neighbors
LIMIT 1

Sample output:

[{"prefix": "185.220.101.0/24", "toxic_neighbors": 150}]

Tip: Don't write WHERE o.isThreat = true RETURN count(o) — that enumerates every IP in the prefix (up to ~1M on hyperscaler blocks) and times out. The counter is precomputed and refreshed with the feed cycles. A registered allocation can be far wider than the routed block, so if the registered-prefix read looks flat, check the announced prefix too.

Pivot the campaign: egress, fingerprints, certificates

Is this a Tor exit, and which relay?

An isTor: true flag means anonymizing egress rather than the operator's own server — different ticket, different response. The Tor-relay identity survives IP rotation, so you can track the operator across address changes.

// Tor-exit identity behind an IP (survives IP rotation)
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(r:TOR_RELAY)
RETURN ip.name, r.name AS relay_fingerprint
LIMIT 5

Sample output:

[
  {"ip.name": "185.220.101.1", "relay_fingerprint": "6c64100d8f7050e76f420ce404031eabc7101124"},
  {"ip.name": "185.220.101.1", "relay_fingerprint": "8f744605199e75c26f74e818bde50d9a7325ec94"}
]

Tip: Pair this with the isTor / isAnonymizer flags from the verdict recipe. Anonymizing egress means you can't attribute the human behind it from the IP alone — a known posture, not a mystery.

Track C2 across changing domains via TLS fingerprint

C2 operators rotate domains and IPs but reuse the same TLS stack. The JA3/JARM fingerprint pins the server software, so you can find other IPs presenting the same fingerprint as a known-bad host — infrastructure the operator forgot to change.

// IPs sharing a TLS fingerprint with a known indicator
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT)
MATCH (f)<-[:EMITS_TLS_FINGERPRINT]-(peer:IPV4)
WHERE peer.name <> ip.name
RETURN f.name AS fingerprint, collect(DISTINCT peer.name)[..25] AS peers_same_tls
LIMIT 1

Tip: Bound the fan-out — a common JARM can be shared by thousands of benign hosts, so slice the collected list ([..25]) and run each candidate through explain() before calling it related. A rare fingerprint shared by a handful of IPs is the strong signal. Fingerprint coverage is still rolling out, so an empty result means "not observed yet".

Discover subdomains from Certificate Transparency

A phishing kit registers secure-login.victim-corp.example and gets a cert — which lands in CT logs the moment it's issued, often before DNS resolves. CT surfaces SANs and subdomains you won't find by resolving the apex.

// Subdomains / SANs seen in Certificate Transparency for a domain
MATCH (h:HOSTNAME {name: "paypal.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation
LIMIT 25

Tip: CT is your earliest-warning surface for lookalike infrastructure. Combine it with whisper.variants("paypal.com") (next recipe) to catch typosquats that have already pulled a certificate.

Catch the lookalike domain behind the lure

A user reports a phishing email from paypa1.com. You want every registered lookalike of your brand and a verdict on each — without brainstorming permutations by hand.

// Registered typosquats / lookalikes of a brand
CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidenceLabel
WHERE exists
RETURN variant, method, confidenceLabel
LIMIT 15

Tip: exists: true means registered, not malicious — pivot each hit straight through explain(variant) for a verdict. Generation covers character omission, repetition, transposition, keyboard-adjacent swaps, homoglyphs, bitsquatting, TLD swap, and more — see whisper.variants(). Also available as the domain_variants MCP tool.

WHOIS, DNS & evidence collection

Quick WHOIS check

You need registration details for a suspicious domain — registrar, contact emails, phones.

// WHOIS registration profile for a domain
MATCH (h:HOSTNAME {name: "cloudflare.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)
OPTIONAL MATCH (h)-[:HAS_PHONE]->(p:PHONE)
RETURN h.name,
       collect(DISTINCT r.name) AS registrars,
       collect(DISTINCT e.name) AS emails,
       collect(DISTINCT p.name) AS phones
LIMIT 1

Sample output:

[{
  "h.name": "cloudflare.com",
  "registrars": ["iana:1910"],
  "emails": ["domains@cloudflare.com", "noreply@data-protected.net"],
  "phones": ["+10000000000", "+16503198930"]
}]

Tip: Use OPTIONAL MATCH for WHOIS fields — not every domain has every field. A plain MATCH would drop the whole row for a partially-registered domain. To pivot to siblings sharing a registrant email, reverse HAS_EMAIL: (:EMAIL {name:"..."})<-[:HAS_EMAIL]-(:HOSTNAME).

Has the registrar changed? (WHOIS history)

A sudden registrar transfer on an established domain is a takeover or resale signal. whisper.history() returns the timestamped WHOIS trail in one call.

// WHOIS history — registrar transfers, registrant changes
CALL whisper.history("google.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 3

Tip: whisper.history needs a key (not on the anonymous tier). For an IP, ASN, or prefix the same call returns BGP routing history instead — keep a LIMIT on it and expect a longer round trip; BGP history over a large network can take many seconds.

Who controls DNS?

The nameserver is often the clearest tell of who manages the infrastructure. NAMESERVER_FOR points server → domain, so traverse it backwards.

// Authoritative nameservers for a domain
MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h:HOSTNAME {name: "google.com"})
RETURN ns.name LIMIT 10

Sample output:

[
  {"ns.name": "ns1.google.com"},
  {"ns.name": "ns2.google.com"},
  {"ns.name": "ns3.google.com"},
  {"ns.name": "ns4.google.com"}
]

Tip: Same direction rule for mail — a domain's MX is (:HOSTNAME {name:"..."})<-[:MAIL_FOR]-(mx:HOSTNAME). Free or bulletproof-hosting nameservers on an otherwise-corporate domain are worth flagging.

De-cloak the real origin behind a CDN

The IP you see is the CDN edge. To geolocate, block, or attribute the actual server you need the origin behind it — whisper.origins() derives candidates from MX/SPF/sibling and crawl signals.

// Candidate real origin IPs behind a CDN/proxy
CALL whisper.origins("cloudflare.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
ORDER BY confidence DESC
LIMIT 5

Tip: Results are ranked highest-confidence first, each with the methods[] that found it so you can weigh the evidence. A high-confidence origin on a different ASN than the CDN edge is your real block target.

Full infrastructure trace for the report

Document the complete path from domain to network owner. A domain resolving to IPs on different ASNs can mean multi-CDN, load balancing, or — rarely — a hijack artifact; capture every row.

// Full chain: domain -> IP -> BGP prefix -> ASN -> network name
MATCH (h:HOSTNAME {name: "cloudflare.com"})
      -[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
      -[:HAS_NAME]->(n:ASN_NAME)
RETURN h.name AS host, ip.name AS ip, ap.name AS prefix,
       a.name AS asn, n.name AS network
LIMIT 10

Sample output:

[
  {"host": "cloudflare.com", "ip": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."},
  {"host": "cloudflare.com", "ip": "104.16.133.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}
]

Batch IOC enrichment

You've got a list of indicators from an alert and want them all enriched in one round-trip. UNWIND turns the list into rows.

// Enrich multiple IPs in one request, with reconciled verdict per IP
UNWIND ["185.220.101.1", "104.16.132.229", "8.8.8.8"] AS ip_addr
MATCH (ip:IPV4 {name: ip_addr})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
RETURN ip_addr, ap.name AS prefix, a.name AS asn,
       ip.verdictLevel AS level, ip.verdictBlocking AS block
LIMIT 25

Sample output:

[
  {"ip_addr": "185.220.101.1", "prefix": "185.220.101.0/24", "asn": "AS60729", "level": "HIGH", "block": true},
  {"ip_addr": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "level": "NONE", "block": false},
  {"ip_addr": "8.8.8.8", "prefix": "8.8.8.0/24", "asn": "AS15169", "level": "NONE", "block": false}
]

Tip: UNWIND handles hundreds of indicators per query. To run the full scored verdict on each, chain CALL explain(ip_addr) after the UNWIND — but each explain() is a separate backend call, so keep that list modest. Reading verdictLevel/verdictBlocking straight off the node is the cheaper batch path.

Hit it from the command line

Everything above is one HTTP POST. Anonymous works for a 2-hop sanity check; a free key unlocks 3.

curl -s https://graph.whisper.security/api/query \
  -H "Content-Type: application/json" \
  -d '{"query":"MATCH (ip:IPV4 {name:\"185.220.101.1\"}) RETURN ip.verdictLevel, ip.verdictBlocking, ip.isTor"}'

Tip: Add a free-key header (-H "X-API-Key: $WHISPER_KEY"; Authorization: Bearer also works) to run the 3-hop attribution chains. Wire the same call into a SOAR playbook and every alert arrives pre-enriched. Full request and response shapes: API Reference.

Key concepts: Indicator of compromise · ASN reputation · Reconciled verdict.

Going deeper

  • More patternsCross-Layer Patterns has the copy-paste pivots that apply across every use case, including batch existence checks and bounded fan-out.
  • Every label, edge, and property — the Graph Schema, and full procedure signatures in Procedures.
  • Feeds behind the verdictThreat Feeds & Categories lists all 43 feeds and 25 categories and their weights.
  • Agent-driven triage — point your SOAR or assistant at the MCP surface; see AI & Agents and MCP Setup.

Splunk equivalents

Enriching events inline rather than running ad-hoc Cypher? The same workflows in SPL: Splunk Use Cases for Infrastructure Intel. For whisperlookup and whisperquery see Search Commands.