# Your first investigation

> A worked WhisperGraph MCP investigation: explain_indicator, the coverage block, a Tor-exit pivot, and how to falsify a verdict against the enclosing network before you escalate.

*Source: https://www.whisper.security/docs/ai/mcp/investigation*

---
The other pages document the surface. This one uses it.

Every number below came from a real run against `mcp.whisper.security` on 2026-09-02. Your run will differ — feeds churn — but the shape of the reasoning will not.

> **Read `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says. `whisper.explain` does not return `coverage` at all.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

## The alert

A proxy log shows an internal host reached `185.220.101.1`. The feed that flagged it scores the address `18.93 / LOW` — low enough to close, listed enough that closing it is a decision somebody will ask you about.

## The question

*Do I escalate, and what would tell me I'm wrong?*

Not "what does this IP score". The score is an input to that question, and on this indicator it is the input that points the wrong way.

## What this cannot answer

The graph answers what the address is listed for and what it is attached to: the feeds and the categories behind the listings, the Tor relays it runs, and the prefix and the ASN around it. Four scope notes sit alongside that, named here rather than discovered at the end:

- **Direction comes from your own logs.** The graph holds the address, not the connection, so outbound Tor use and an inbound connection look the same here.
- **The reading is of the external address.** Whether the internal host is compromised is a separate question: a Tor exit is a plausible destination for an ordinary privacy tool and for a beacon alike.
- **How much was looked at is on the verdict itself.** That is its coverage qualifier, and reading it is the first step below rather than a footnote — [Coverage](/docs/whisper-graph/procedures/coverage).
- **On a CIDR or an ASN the number is an aggregate**, not a per-host score. `explain_indicator` scores the range as a whole — listed addresses, inherited subnet scores, density — and `factors[]` shows how; [Falsify it](#falsify-it) reads one, and [`explain()`](/docs/whisper-graph/procedures/explain) documents the fields.

## What do we know

```
explain_indicator({ indicator: "185.220.101.1" })
```

```json
{
  "indicator": "185.220.101.1", "type": "ip", "found": true,
  "score": 18.93, "level": "LOW", "verdictScore": 16.84,
  "explanation": "185.220.101.1 is listed in 7 threat feed(s). Score 18.9 (Low - limited risk). sources[] also carries 1 listing(s) in non-threat categories (tor): rosters and compliance lists rather than abuse reports, excluded from the threat-feed count.",
  "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": "tor-exit-nodes", "weight": 0.5, "category": "tor", "threatCategory": false },
    { "feedId": "firehol-level2", "weight": 1.3, "category": "blacklists", "threatCategory": true },
    "…"
  ],
  "threatFeedCount": 7, "nonThreatFeedCount": 1,
  "source": "live-explain", "detail": "full",
  "coverage": { "granularity": "ipv4", "scope": "node-only", "sharedHost": false, "dataCoverage": "unknown" }
}
```

Three things matter more than the number.

`factors[]` is the arithmetic, not a summary of it. Eight listings with a combined weight of 6.30, damped logarithmically, clamped, then an age boost. You can check it. If a stakeholder asks why the score is 19 and not 80, the answer is on the row.

`threatFeedCount: 7` and `nonThreatFeedCount: 1` say that one of the eight listings is a roster, not an abuse report — and `sources[]` names it: `tor-exit-nodes`, category `tor`. That is already a hint about what this address is.

`coverage.scope: "node-only"` is the part people skip. It means the verdict was computed for **this address and nothing else** — not the /24 it sits in, not the ASN that routes it. LOW is a statement about one host's feed listings. It is not permission to close the ticket, and [Falsify it](#falsify-it) is where that matters.

## Why

A score compresses eight listings into one number and throws away what they were about. Get the categories back from the graph:

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)-[:BELONGS_TO]->(cat:CATEGORY)
RETURN f.id AS feed, cat.id AS category
LIMIT 20
```

| feed | category |
|------|----------|
| greensnow | blacklists |
| stamparm-ipsum | blacklists |
| firehol-level2 | blacklists |
| tor-exit-nodes | tor |
| stopforumspam-listed-ip-7d | spam |
| duggytuxy-datashield-critical | blacklists |

No C2. No phishing. No malware. Four generic abuse blacklists, one spam list — and one feed that names a mechanism: `tor`. (The graph walk returns six feeds where the verdict counted eight sources: the verdict engine reads the feed catalogue directly, so `sources[]` is the fuller count, and the graph walk is where the categories and the pivots live.)

The "our host is talking to malware infrastructure" hypothesis just got weaker, and a different one appeared. Follow the new one.

Sort or aggregate the pattern as you like — `ORDER BY category, feed`, or `count(*)` per category — the chain holds. If it ever returned nothing, that would be a claim about your query, not about the host: a verdict that genuinely has no data comes back as a *populated row saying `no-data`*, never as an empty result set.

## The pivot the score did not suggest

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(t:TOR_RELAY)
RETURN t.name AS relay_fingerprint, ip.isTor, ip.isAnonymizer
LIMIT 5
```

| relay_fingerprint | isTor | isAnonymizer |
|-------------------|-------|--------------|
| 6c64100d8f7050e76f420ce404031eabc7101124 | true | true |
| 8f744605199e75c26f74e818bde50d9a7325ec94 | true | true |
| d1e5c406d14429bd36bacc6eee64e6b8c5833e7b | true | true |
| fb4a0e4f470b36e7a89159a8569530a47c292ba5 | true | true |

Four relay fingerprints. **This is a Tor exit relay**, and the two boolean flags on the IP corroborate it.

That changes the incident. "Internal host contacted a low-scoring blacklisted IP" and "internal host contacted the Tor network" are different tickets, with different playbooks and different owners — and outbound Tor from a corporate subnet is usually a policy question about the *internal* host, not a reputation question about the external one.

**This is the pivot the score never suggested.** `18.93 / LOW` contains no hint of it; the `tor` roster in `sources[]` did, and the graph confirms it. It is one hop away, and the whole point of a graph is that the hop is cheap.

## Falsify it

The verdict above was `node-only`. So ask the enclosing network, which the verdict explicitly did not cover:

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN p.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5
```

| prefix | asn | network |
|--------|-----|---------|
| 185.220.101.0/24 | AS60729 | TORSERVERS-NET - Stiftung Erneuerbare Freiheit |

The /24 is routed by a network whose registered name is the Torservers non-profit. The Tor read is now corroborated from a second, independent layer — routing rather than threat feeds.

Now score the prefix itself:

```
explain_indicator({ indicator: "185.220.101.0/24" })
```

```json
{
  "indicator": "185.220.101.0/24", "type": "network", "found": true,
  "score": 81.9, "level": "CRITICAL", "verdictScore": 81.9,
  "factors": [
    "Listed IPs: 291 IPs found → 10 × log₂(291 + 1) = 81.90",
    "Listed subnets: 35 found, max score 2.38 → contributes 1.90 (80% inheritance)",
    "…"
  ],
  "source": "live-explain", "detail": "full",
  "coverage": { "granularity": "cidr", "scope": "node-only", "sharedHost": false, "dataCoverage": "unknown" }
}
```

**The block scores 81.9 / CRITICAL: the engine counts 291 listings across the /24 and its nested subnets.** The host reads LOW; the block it lives in reads CRITICAL. Both are true, and only one of them was in the answer you started with.

> **On a CIDR or an ASN, `score` is an aggregate over the whole range**, and `factors[]` shows how it was built — listed addresses, inherited subnet scores, density. When the engine has evidence but no aggregate to report, the row says so with `score: null`, `level: UNSCORED` and `scoreUnavailable: true` rather than reading clean. **A low or missing `score` on a CIDR or ASN is not a clean network — read `factors[]`.** On an IP or a hostname, `score` is the value.

## The conclusion

> `185.220.101.1` is a **Tor exit relay**, not attacker-controlled infrastructure. It runs 4 exit relays, sits in `185.220.101.0/24` (a block the engine scores CRITICAL on hundreds of listings), and is routed by **AS60729 / TORSERVERS-NET**, a Tor infrastructure non-profit. Its listings are generic abuse and spam blacklists plus a Tor roster that the verdict itself sets aside as a non-threat category — no C2, phishing or malware category anywhere.
>
> **Reclassify from "external threat" to "outbound Tor usage".** The question to answer is why an internal host is reaching the Tor network, not whether this IP is malicious.
>
> **What would change this conclusion:**
> - A C2, phishing or malware **category** appearing on this IP — the listings step above returns categories, so re-run it rather than trusting the score.
> - The connection being **inbound** rather than outbound. Nothing above establishes direction; the graph does not know, and only your own logs do.
> - `OPERATES_EXIT_NODE` returning nothing on a later run — relay membership churns, and a former exit that is still blacklisted reads very differently.
> - Evidence that the internal host was compromised **independently**. A Tor exit is a plausible destination for both an ordinary privacy tool and a beacon.

The last four lines are the deliverable. A verdict with a falsification list can be argued with; a verdict without one can only be believed or ignored.

## What this cost

Five tool calls: one `explain_indicator`, three `query` calls, one more `explain_indicator`. Every one returned an `evidence` block with the exact Cypher, the row count and the timing, so every claim above is traceable to a query someone else can re-run.

If you would rather not drive the pivots by hand, `run_workflow({ runs: [{ slug: "indicator-enrichment", input: "185.220.101.1" }] })` runs the same shape as a prepared 19-step investigation and returns a rendered report with a numbered evidence appendix. The [workflow gallery](/docs/ai/mcp/workflow-gallery) lists all twelve.

## The three habits

1. **Read `coverage` before `score`.** `node-only` means the enclosing prefix and ASN were not evaluated. `level: NONE` means "not listed"; `band: UNKNOWN` means "never seen". They look identical and mean opposite things.
2. **Ask why, not just how much.** The category is where the pivot lives. A score is a compression of it.
3. **Zero rows is a claim about your query.** A `no-data` result from the verdict engine is a *populated row that says no-data* — never an empty result set. If you get zero rows back, suspect the query first.

## Next

- [Workflow gallery](/docs/ai/mcp/workflow-gallery) — the same investigations, prepared, in one call.
- [Reference](/docs/ai/mcp/reference) — every tool, with input shapes and response fields.
- [Query language](/docs/ai/mcp/query) — what the query tool accepts, and what it refuses before the database sees it.
