Skip to content
Get started
Skip navigation
Get started

Your first investigation

One alert, worked end to end over the connector: read the verdict's scope, find out why the indicator is listed, follow the pivot the score never suggests, and falsify the conclusion before you write it down.

Published

View as Markdown
On this page (11)

Your first investigation Documentation

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 — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says. whisper.explain does not return coverage at all. Full contract: Coverage — what we looked at.

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

Four things, named here rather than discovered at the end:

  • Which direction the connection went. Nothing in the graph records that your host initiated it. Only your own logs separate outbound Tor use from an inbound connection.
  • Whether the internal host is compromised. This page reads the external address. A Tor exit is a plausible destination for an ordinary privacy tool and for a beacon alike.
  • How much of the answer was looked at. That is the verdict's own coverage qualifier, and reading it is the first step below rather than a footnote — Coverage.
  • A per-host number for a whole range. On a CIDR or an ASN, explain_indicator scores the range as an aggregate — listed addresses, inherited subnet scores, density — and factors[] shows how; Falsify it reads one, and explain() documents the fields.

What do we know

text
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 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 · runnablegraph.whisper.securitySign in to run
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

feedcategory
greensnowblacklists
stamparm-ipsumblacklists
firehol-level2blacklists
tor-exit-nodestor
stopforumspam-listed-ip-7dspam
duggytuxy-datashield-criticalblacklists

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 — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at., never as an empty result set.

The pivot the score did not suggest

cypher · runnablegraph.whisper.securitySign in to run
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_fingerprintisTorisAnonymizer
6c64100d8f7050e76f420ce404031eabc7101124truetrue
8f744605199e75c26f74e818bde50d9a7325ec94truetrue
d1e5c406d14429bd36bacc6eee64e6b8c5833e7btruetrue
fb4a0e4f470b36e7a89159a8569530a47c292ba5truetrue

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 · runnablegraph.whisper.securitySign in to run
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

prefixasnnetwork
185.220.101.0/24AS60729TORSERVERS-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:

text
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 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 — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. 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 — the same investigations, prepared, in one call.
  • Reference — every tool, with input shapes and response fields.
  • Query language — the error envelope, the safety rules, and the traversal landmines.