# explain() — Threat Verdicts

> CALL explain() for scored threat verdicts on IPs, hostnames, ASNs, CIDRs, hashes and CVEs: factors, feed evidence, levels, the bundle form, batches.

*Source: https://www.whisper.security/docs/whisper-graph/procedures/explain*
*Published: 2026-07-06*
*Last updated: 2026-10-01*

---
`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](/docs/ai/mcp/reference).

## What it returns

One row. For an indicator the engine can score, the columns are these, in this order:

| Column | Meaning |
|--------|---------|
| `indicator`, `type` | the input echoed back, plus the detected type |
| `available`, `cached` | transport fields: whether the verdict backend answered, and whether the row came from cache |
| `found` | whether 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` |
| `score` | the raw threat arithmetic for this indicator, explained line by line in `factors[]` |
| `level` | the verdict band: `NONE`, `INFO`, `LOW`, `MEDIUM`, `HIGH` or `CRITICAL` |
| `explanation` | a one-sentence summary of the verdict |
| `factors[]` | the scoring arithmetic, step by step |
| `sources[]` | each listing feed as `{feedId, weight, firstSeen, lastSeen}` |
| `breakdown` | the component scores behind an ASN verdict; `null` for other types |
| `advisory` | why the verdict was shaped the way it was, such as `allowlist-vouched` on a vouched public resolver; `null` when nothing applies |
| `verdictScore` | the reconciled verdict score: the same number `whisper.assess` returns and the node's `verdictScore` property carries |
| `coverage` | what we hold on the indicator, in the vocabulary [Coverage](/docs/whisper-graph/procedures/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 expect=rows>0,no-null-columns seed=185.220.101.1 verified=2026-09-02
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 expect=rows>0,no-null-columns seed=AS13335 verified=2026-09-02
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 expect=rows>0,no-null-columns seed=8.8.8.0/24 verified=2026-09-02
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 expect=rows>0,no-null-columns seed=CVE-2021-44228 verified=2026-09-02
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 expect=rows>0 seed=185.220.101.1 verified=2026-09-02
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 expect=rows>0 seed=1.1.1.1 verified=2026-09-02
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:

    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` licenses reading a `NONE` as clean. On `no-data`, the zero is a statement about our feeds, not about the indicator, and the [no-data playbook](/docs/whisper-graph/procedures/coverage#no-data) 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()`:

    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` licenses the word "clean". Every other value is not-clean — and `no-data` 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.

| `coverage` | What it means | What to do |
|---|---|---|
| `known-clean` | 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` | **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` | 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` | 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` is a row that says `no-data`; 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](/docs/whisper-graph/procedures/coverage#procedure-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` | **Yes** | Threat 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-only` 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](/docs/whisper-graph/procedures/coverage#not-assess-values).

## 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 expect=rows>0 seed=185.220.101.1 verified=2026-09-02
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](/docs/whisper-graph/threat-feeds).

## Related pages

- [Indicator Triage (SOC)](/docs/recipes/soc): full triage recipes built around `explain()`.
- [whisper.variants() — Lookalike Generation](/docs/whisper-graph/procedures/variants): generate lookalike domains, then pivot each registered hit through `explain()`.
- [Threat Feeds & Categories](/docs/whisper-graph/threat-feeds): the 134 feeds and 32 categories behind the score.
