explain() — Threat Verdicts

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

Updated July 2026Procedures

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:

FieldMeaning
indicator, typethe input echoed back, plus the detected type
foundwhether any loaded feed lists the indicator
scorethe numeric threat score
levelthe verdict band, NONE through CRITICAL
explanationa 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 a coverage field (known-clean / structural-only / no-data) and is meant to be gated on it: no-data means 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.