Skip to content
WhisperGraph
Skip navigation

explain() — Threat Verdicts

Scored threat verdicts for IPs, hostnames, ASNs, CIDRs, file hashes and CVE ids, with the factors and feed sources behind each score.

Published Last updated

On this page (10)

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, a CIDR range, a file hash or a CVE id, and type echoes what it decided: ip, domain, asn, network, hash or cve. whisper.explain is the same procedure under its namespaced name. 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. For an indicator the engine can score, the columns are these, in this order:

ColumnMeaning
indicator, typethe input echoed back, plus the detected type
available, cachedtransport fields: whether the verdict backend answered, and whether the row came from cache
foundwhether the engine produced a verdict for the indicator. It is not a coverage statement: an address nobody lists still reads found: true with level: NONE
scorethe raw threat arithmetic for this indicator, explained line by line in factors[]
levelthe verdict band: NONE, INFO, LOW, MEDIUM, HIGH or CRITICAL
explanationa one-sentence summary of the verdict
factors[]the scoring arithmetic, step by step
sources[]each listing feed as {feedId, weight, firstSeen, lastSeen}
breakdownthe component scores behind an ASN verdict; null for other types
advisorywhy the verdict was shaped the way it was, such as allowlist-vouched on a vouched public resolver; null when nothing applies
verdictScorethe reconciled verdict score: the same number whisper.assess returns and the node's verdictScore property carries
coveragewhat we hold on the indicator, in the vocabulary Coverage documents; for a host or an address, the same value whisper.assess returns. Read it before level

The procedure is multi-shape: the columns depend on what you pass, so YIELD * is rejected. Name the columns your investigation reads, as every example below does.

For triage, read level and verdictScore. score is the raw feed arithmetic: the feed count, each feed's weight, a recency boost for fresh sightings and an age boost for indicators that have stayed on lists, combined the way factors[] shows, with sources[] naming the feeds. That is what makes the verdict inspectable end to end.

Examples

Verdict for an IP

cypher · runnablegraph.whisper.securitySign in to run
CALL explain("185.220.101.1")
YIELD indicator, type, found, score, level, explanation, factors, sources, verdictScore
RETURN indicator, type, found, score, level, explanation, factors, sources, verdictScore

json
[
  {
    "indicator": "185.220.101.1",
    "type": "ip",
    "found": true,
    "score": 18.932481576197464,
    "level": "LOW",
    "explanation": "185.220.101.1 is listed in 8 threat feed(s). Score 18.9 (Low - limited risk).",
    "factors": [
      "Listed in 8 source(s) with combined weight 6.30",
      "Base score: 6.30 × log₂(8 + 1) = 19.97, clamped to 17.69",
      "Age boost: ×1.07 (on lists for 7 days)",
      "Final score: 17.69 × 1.0 × 1.0705 = 18.93"
    ],
    "sources": [
      {"feedId": "borestad-abuseipdb-s100-30d", "weight": 1.4, "firstSeen": "2026-08-26T14:16:16Z", "lastSeen": "2026-09-02T15:38:37Z"},
      {"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-08-26T14:49:29.177Z", "lastSeen": "2026-09-02T11:09:45.079209110Z"},
      {"feedId": "blocklist-net-ua", "weight": 1.2, "firstSeen": "2026-08-26T14:50:06.212Z", "lastSeen": "2026-09-02T11:15:46.229152851Z"},
      {"feedId": "firehol-level2", "weight": 1.3, "firstSeen": "2026-08-31T08:48:42Z", "lastSeen": "2026-09-02T12:04:44.777054906Z"},
      {"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-08-26T14:45:23.568618858Z", "lastSeen": "2026-09-02T16:08:44.783090628Z"},
      {"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-08-26T14:49:29.279Z", "lastSeen": "2026-09-01T23:06:44.749439308Z"},
      {"feedId": "duggytuxy-datashield-critical", "weight": 1.5, "firstSeen": "2026-08-26T14:49:38.569Z", "lastSeen": "2026-09-02T15:39:45.375596457Z"},
      {"feedId": "greensnow", "weight": 1.0, "firstSeen": "2026-08-31T08:13:44.867Z", "lastSeen": "2026-09-02T06:59:44.783196837Z"}
    ],
    "verdictScore": 16.84
  }
]

That is a live read, captured on 2026-09-02. The same address will score differently once the feeds behind it move.

The same call for ASNs, hostnames, CIDR ranges, hashes and CVEs

A hostname works exactly like the IP above. The other types change what the columns mean, so read them as follows.

An ASN is reasoned from network reputation, not from feed listings. The row carries a breakdown of the component scores where an IP carries sources, and the response carries an explain-verdict-axis-unavailable advisory telling you that score and level are placeholders on this row. Read breakdown.reputationScore and breakdown.reputationCategory, where higher means more trustworthy, and do not compare them with a threat band.

cypher · runnablegraph.whisper.securitySign in to run
CALL explain("AS13335")
YIELD indicator, type, found, level, explanation, breakdown
RETURN indicator, type, found, level, explanation, breakdown

A CIDR range is scored as an aggregate: how many of its addresses and subnets are listed, and the resulting threat density. Read explanation and factors[] for the range's picture. score there is a density aggregate, not an address score, so never compare it with an IP's.

cypher · runnablegraph.whisper.securitySign in to run
CALL explain("8.8.8.0/24")
YIELD indicator, type, level, explanation, factors
RETURN indicator, type, level, explanation, factors

A file hash or a CVE id returns a verdict on the same columns: a hash is checked against known-good and threat listings, a CVE against known-exploited and ransomware-campaign intelligence.

cypher · runnablegraph.whisper.securitySign in to run
UNWIND ["44d88612fea8a8f36de82e1278abb02f", "CVE-2021-44228"] AS x
CALL explain(x) YIELD indicator, type, level, explanation
RETURN indicator, type, level, explanation

Ask an ASN for sources, or an IP for breakdown, and you get a null column back, not an error. If a response carries an explain-score-unavailable advisory, the score column holds no usable value for that row: read level, explanation and factors[] instead.

Selecting fields with YIELD

cypher · runnablegraph.whisper.securitySign in to run
CALL explain("185.220.101.1")
YIELD score, level, factors, sources
RETURN score, level, factors, sources

One map column for automation

explain() changes its column set with the indicator type, which is fine when a person is reading and awkward when a program is. whisper.explain.bundle(indicator) returns the same verdict as a single verdict map, so a fixed projection stays valid whatever you pass. The argument is one string, never a list. Read verdict.found before verdict.level.

cypher · runnablegraph.whisper.securitySign in to run
CALL whisper.explain.bundle("1.1.1.1") YIELD verdict
RETURN verdict.indicator AS indicator, verdict.level AS level,
       verdict.score AS score, verdict.found AS found, verdict.explanation AS why

Verdict levels

level bands the score, from NONE (nothing lists the indicator) through INFO, 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, and the advisory column says so (allowlist-vouched). The raw threatScore property on the node itself is never clamped, so the feed evidence stays queryable.

Read coverage before the level

explain() returns coverage; for a host or an address it is the same value whisper.assess() returns. Read it before you read a NONE.

An address nobody lists and a hostname with no node in the graph at all 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. coverage describes what we hold on the indicator:

text
CALL explain("192.0.2.1")                    // an address 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",
         "coverage": "no-data"}

Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses reading a NONE as clean. On no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at., the zero is a statement about our feeds, not about the indicator, and the no-data playbook is the next step. explain() stays the evidence chain (the feeds, the weights, the arithmetic, the timestamps); for the band, the label and evidence[] beside the same coverage value, call whisper.assess():

text
CALL whisper.assess(["<indicator>"]) YIELD host, band, coverage, evidence
RETURN host, band, coverage, evidence

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 note below first.

On a CIDR or an ASN, read the aggregate from explanation, factors[] or breakdown, not from score. score is per-address feed arithmetic on an IP or hostname, a density aggregate on a range, and a placeholder on an ASN, so a row can show a low score beside a high level. level, explanation, factors[] and breakdown are the columns to read there. On an IP or a hostname, score is the value and verdictScore is the reconciled one.

Every Whisper verdict answers two independent questions. band tells you how bad. coverage tells 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, whisper.assessUrl, whisper.explain and whisper.walk all return coverage, and it is not about the same thing in each.

coverageWhat it meansWhat 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:

ProcedureReturns coverage?What its coverage is about
whisper.assessYesThreat coverage. The four values above.
whisper.assessUrlYesA path axis, not a host axis — read the contract before gating on it.
whisper.walkYes, but not a verdictAtlas and vendor adjacency — whether the host is reachable in the graph's structure. Emits presence-axis values only.
whisper.explainYesThreat coverage; for a host or an address, the same value whisper.assess returns. A NONE level from explain() is a score, not a clean verdict: read coverage beside it.

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 reconciled verdict properties stored on the nodes instead; every lookup stays an anchored index hit:

cypher · runnablegraph.whisper.securitySign in to run
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.verdictLevel AS level, ip.verdictBlocking AS blocking, 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.