explain() — Threat Verdicts
Scored threat verdicts for IPs, hostnames, ASNs, and CIDRs, with the factors and feed sources behind each score.
explain() — Threat Verdicts Documentation
CALL explain(indicator) returns a scored threat verdict for a single indicator, plus the evidence behind the score. It auto-detects the indicator type, so the same call works for an IPv4 or IPv6 address, a hostname, an ASN, or a CIDR range. Reach for it before hand-walking LISTED_IN edges: one procedure call replaces the whole traversal and returns an evidence chain you can paste into a ticket.
The verdict is a live read. It reflects whichever feeds are loaded at query time, so the same indicator can score differently tomorrow. The procedure is also exposed to AI agents as the explain_indicator tool on the MCP server.
What it returns
One row with these fields:
| Field | Meaning |
|---|---|
indicator, type | the input echoed back, plus the detected type |
found | whether any loaded feed lists the indicator |
score | the numeric threat score |
level | the verdict band, NONE through CRITICAL |
explanation | a one-sentence summary of the verdict |
factors[] | the scoring arithmetic, step by step |
sources[] | each listing feed, with its weight and first/last-seen timestamps |
The score is computed from the feeds that list the indicator: the feed count, each feed's weight, a recency boost for fresh sightings, and an age boost for indicators that have stayed on lists. factors[] shows how those combine into the final number and sources[] names the feeds, so the verdict is inspectable end to end.
Examples
Verdict for an IP
CALL explain("185.220.101.1")
[
{
"indicator": "185.220.101.1",
"type": "ip",
"found": true,
"score": 5.57,
"level": "MEDIUM",
"explanation": "185.220.101.1 is listed in 3 threat feed(s)...",
"factors": [
"Listed in 3 source(s) with combined weight 2.20",
"Base score: 2.20 × log₂(3 + 1) = 4.40",
"Recency boost: ×1.2 (last seen 8 hours ago)",
"Age boost: ×1.05 (on lists for 4 days)",
"Final score: 4.40 × 1.2 × 1.05 = 5.57"
],
"sources": [
{"feedId": "dan-tor-exit", "weight": 0.5, "firstSeen": "2026-06-23T01:26:36Z", "lastSeen": "2026-06-26T13:56:27Z"},
{"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-06-22T19:25:52Z", "lastSeen": "2026-06-26T13:56:14Z"},
{"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-06-22T19:25:54Z", "lastSeen": "2026-06-26T19:30:43Z"}
]
}
]
The same call for ASNs, hostnames, and CIDR ranges
CALL explain("AS13335")
A hostname or a CIDR range works the same way. The procedure detects the type from the value you pass.
Selecting fields with YIELD
CALL explain("185.220.101.1")
YIELD score, level, factors, sources
RETURN score, level, factors, sources
Verdict levels
level bands the score, from NONE (nothing lists the indicator) through LOW, MEDIUM, and HIGH to CRITICAL. Because the score is recomputed from live feed data, a level can move between reads. If you need a defensible record of what the verdict was at triage time, log the factors[] and sources[] arrays alongside it.
One caveat on well-known infrastructure: a curated allowlist clamps public DNS resolvers such as 1.1.1.1 and 8.8.8.8 to a benign verdict level even when individual feeds list them. The raw threatScore property on the node itself is never clamped, so the feed evidence stays queryable.
No data is not benign
found: false with level NONE means no currently loaded feed lists the indicator, at the granularity you asked about. That is a statement about feed coverage. It says nothing about safety: new infrastructure takes time to appear on lists, and many campaigns rotate through indicators faster than feeds pick them up.
Two habits keep a clean result honest:
- Check the containing network as well.
explain()accepts CIDR ranges and ASNs, so follow a clean IP with a call on its announcing prefix or ASN before you close the ticket. - When an agent or pipeline needs a verdict that states its own coverage, use
whisper.assess, which returns acoveragefield (known-clean/structural-only/no-data) and is meant to be gated on it:no-datameans unknown, not benign. See the procedures overview for its signature.
Batch lookups scale linearly
UNWIND into CALL explain() works, but it makes one backend call per item, so runtime grows with the length of the list. Keep unwound lists short. For bulk triage, read the verdict properties stored on the nodes instead; every lookup stays an anchored index hit:
UNWIND ["185.220.101.1", "104.16.123.96", "8.8.8.8"] AS addr
MATCH (ip:IPV4 {name: addr})
RETURN ip.name AS ip, ip.threatLevel AS level, ip.isThreat AS isThreat, ip.isTor AS isTor
LIMIT 10
Then run explain() on the handful that come back flagged. How the reconciled verdict properties are produced, and the full feed catalog behind them, is covered in Threat Feeds & Categories.
Related pages
- Indicator Triage (SOC): full triage recipes built around
explain(). - whisper.variants() — Lookalike Generation: generate lookalike domains, then pivot each registered hit through
explain(). - Threat Feeds & Categories: the 43 feeds and 25 categories behind the score.