explain() — Threat Verdicts
Scored threat verdicts for IPs, hostnames, ASNs, and CIDRs, with the factors and feed sources behind each score.
On this page (9)
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 |
advisory | a note carried by allowlist-clamped infrastructure; null for everything else |
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.
A bare CALL explain(x) hands back the transport fields (available, cached) and the advisory slot alongside the verdict. Name the columns your investigation actually reads with YIELD, as the examples below do, and the result stays legible.
Examples
Verdict for an IP
CALL explain("185.220.101.1")
YIELD indicator, type, found, score, level, explanation, factors, sources
RETURN indicator, type, found, score, level, explanation, factors, sources
[
{
"indicator": "185.220.101.1",
"type": "ip",
"found": true,
"score": 21.440094319478078,
"level": "LOW",
"explanation": "185.220.101.1 is listed in 6 threat feed(s). Score 21.4 (Low - limited risk).",
"factors": [
"Listed in 6 source(s) with combined weight 6.00",
"Base score: 6.00 × log₂(6 + 1) = 16.84",
"Recency boost: ×1.2 (last seen 19 hours ago)",
"Age boost: ×1.06 (on lists for 5 days)",
"Final score: 16.84 × 1.2 × 1.06 = 21.44"
],
"sources": [
{"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-08-03T16:21:48.400511263Z", "lastSeen": "2026-08-08T10:06:47.505353314Z"},
{"feedId": "firehol-abusers-1d", "weight": 1.5, "firstSeen": "2026-08-03T16:21:20.771305802Z", "lastSeen": "2026-08-07T07:51:07.776194553Z"},
{"feedId": "greensnow", "weight": 1.0, "firstSeen": "2026-08-03T16:22:08.761536178Z", "lastSeen": "2026-08-06T07:38:10.889494390Z"},
{"feedId": "firehol-level2", "weight": 1.3, "firstSeen": "2026-08-04T17:38:44.217845576Z", "lastSeen": "2026-08-04T17:38:44.217845576Z"},
{"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-08-03T16:22:10.328361154Z", "lastSeen": "2026-08-06T07:38:35.857131131Z"},
{"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-08-03T16:22:08.606554919Z", "lastSeen": "2026-08-08T23:45:58.114818276Z"}
]
}
]
That is a live read, captured on 2026-08-09. The same address will score differently once the feeds behind it move.
The same call for ASNs, hostnames, and CIDR ranges
CALL explain("AS13335")
YIELD indicator, type, found, score, level, explanation
RETURN indicator, type, found, score, level, explanation
A hostname or a CIDR range works the same way. The procedure detects the type from the value you pass.
One difference is worth knowing before you write the YIELD: an ASN verdict is reasoned from network reputation rather than from feed listings, so it returns a breakdown of the component scores where an IP or hostname returns sources. Ask an ASN for sources and you get a null column back, not an error.
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.
What explain() cannot tell you
explain() cannot tell youexplain() does not return coverage, and its output cannot distinguish "we checked and found nothing" from "we have never seen this host."
Both come back as score: 0.0, level: NONE, found: true, available: true, and the sentence "Not listed in any threat intelligence feed". That sentence describes the feeds. It does not describe the host:
CALL explain("192.0.2.1") // an address we hold, with no listings
CALL explain("nonexistent-zz-9q7.example") // a hostname with no node in the graph at all
both -> {"available": true, "found": true, "score": 0.0, "level": "NONE",
"explanation": "Not listed in any threat intelligence feed"}
Pair every explain() with an assess(). explain() is the evidence chain — the feeds, the weights, the arithmetic, the timestamps. assess() is the coverage statement. You need both.
CALL whisper.assess(["<indicator>"]) YIELD host, band, coverage, evidence
RETURN host, band, coverage, evidence
Read coverage first. If it is no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at., explain()'s zero is a statement about our feeds, not about the indicator, and the no-data playbook is the next step.
Check the containing network too. 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 — reading the caveat below first.
Danger — silent-wrong. On a CIDR or an ASN, read the aggregate from
explanationandfactors[], not fromscore. Thescorecolumn is scoped to a single indicator; on a range it does not carry the range's value, so a row can show a lowscorebeside a highlevel.level,explanationandfactors[]are the columns to read there. On an IP or a hostname,scoreis the value.
Every Whisper verdict answers two independent questions.
bandtells you how bad.coveragetells you what we actually looked at. Read both. They are a grid, not a ladder.
Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean". Every other value is not-clean — and no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. and
deadline-hit mean unknown, which is a different thing again.
whisper.assess and whisper.assessUrl return coverage. whisper.explain does not.
coverage | What it means | What to do |
|---|---|---|
| known-clean — coverage: known-clean. In coverage, no malicious evidence. | We hold data at this granularity and nothing malicious is in it. | Treat as clean. This is the only value that licenses closing a ticket on "clean." |
| malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. | Some positive evidence of malice exists. It may be a single feed at weight 0.5. It does not mean the band is high. | Read evidence[] for feed-source-count, then run explain() for the per-feed provenance, weights and timestamps. A count of 1 on a low-weight aggregate list is a lead, not a finding. |
| ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. | The evidence points both ways — for example an anonymising-egress signal alongside generic abuse listings. | Escalate to a human. Do not automate a decision on this value. |
| no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. | We have never observed this host. | Unknown. Never benign. Ask a different question — the container, the operator, the age — and escalate with "we have no observation of this host", never with "it came back clean." |
Every one of these arrives as a populated row. no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. is a row that says no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at.; it is never an empty result set. If a query returns zero rows, the first hypothesis is that the query is wrong, not that the host is clean.
Which procedure carries coverage —:
| Procedure | Returns coverage? | What its coverage is about |
|---|---|---|
whisper.assess | Yes | Threat coverage. The four values above. |
whisper.assessUrl | Yes | A path axis, not a host axis — read the contract before gating on it. |
whisper.walk | Yes, but not a verdict | Atlas and vendor adjacency — whether the host is reachable in the graph's structure. Emits presence-axis values only. |
whisper.explain | No | Returns score, level, explanation, factors and sources. There is no coverage column, so a NONE level from explain() is not a clean verdict. |
structural-onlywalk — a whisper.walk value describing atlas adjacency. Not an assess coverage value. Do not gate on it. is a whisper.walk value describing atlas adjacency. It is not a
whisper.assess value, and a branch keyed on it in an assess result is unreachable — see
the full contract.
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 76 feeds and 31 categories behind the score.