Skip to contentSkip navigation

Attribution & Law Enforcement

Pivot from indicators to operators using WHOIS, registrar relationships, hosting providers, dated routing and registration history, Tor-relay identity, and observed dark-web services.

On this page (19)

Attribution & Law Enforcement Documentation

You build infrastructure maps that hold up in court. These recipes take you to the responsible network operator and its jurisdiction, the dated registration and routing record, the relay behind a Tor exit, and the dark-web services the graph has observed — every result sourced from public data and pairable with a timestamp, so the pivot you make at 2am survives disclosure six months later. Anchor each query on a known indicator, keep the LIMIT, and capture the response alongside the moment you ran it. For the legal-process side of attribution you want the responsible network operator and its jurisdiction; for the relational side you want shared registrant, shared infrastructure, and the dated history that proves when a link existed.

Run it live: Digital Infrastructure Mapping · Build the takedown evidence package — each opens with a live result you can rerun on your own indicator.

New to the graph? Start with Getting Started, and keep the Graph Schema and Procedures open. Run everything below at https://graph.whisper.security/api/query.

Key concepts: Infrastructure pivoting · Reconciled verdict · Tor exit node · RDAP.

Evidentiary hygiene. The graph is continuously refreshed, so a result is a point-in-time observation. For anything destined for an affidavit or warrant return, pair the query result with the moment you ran it and, where the data is historical, with the whisper.history snapshot timestamp. "Captured from WhisperGraph on <date/time>, sourced from <feed/registry>" is the citation pattern.

Attribution: IP to responsible operator

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.

IP attribution chain

Why it's hard with flat tools. A WHOIS-on-IP lookup gives you a netblock and an org string, but not the announcing ASN, and the announced prefix often differs from the allocated one. You end up cross-referencing a routing-table dump by hand.

What the graph does. One traversal takes the IP to the prefix actually being announced, to the ASN announcing it, to that network's registered name — the operator you serve process on.

cypher · runnablegraph.whisper.securitySign in to run
// IP -> announced prefix -> ASN -> network name (the legal-process target)
MATCH (ip:IPV4 {name: "185.220.101.1"})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5

json
[{"ip": "185.220.101.1", "prefix": "185.220.101.0/24", "asn": "AS60729", "network": "TORSERVERS-NET - Stiftung Erneuerbare Freiheit"}]

The asn.name field is the AS number; the registered network name lives on ASN_NAME (reach it via the virtual HAS_NAME hop), and the registrant company on (a)-[:REGISTERED_BY]->(:ORGANIZATION). CALL explain("AS60729") adds the verdict and contributing feeds in one call. Network names are read live, so capture the string with the timestamp — the sample above carried a different registered name a few weeks earlier.

IP to jurisdiction

Why it matters. The announcing operator and the IP's geolocation can sit in different countries — that distinction drives which MLAT or domestic process applies.

cypher · runnablegraph.whisper.securitySign in to run
// IP -> city -> country, for jurisdiction
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LOCATED_IN]->(city:CITY)
      -[:HAS_COUNTRY]->(country:COUNTRY)
RETURN ip.name AS ip, city.name AS city, country.name AS country
LIMIT 5

json
[{"ip": "185.220.101.1", "city": "Brandenburg, DE", "country": "DE"}]

Anycast and CDN addresses often carry no city edge and return no rows rather than a wrong city; for those, read the registered country of the announcing prefix instead: (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:HAS_COUNTRY]->(co:COUNTRY).

Operator's physical footprint

Why it's hard with flat tools. Knowing an ASN is one thing; knowing which datacenter or internet exchange it physically sits in — useful for identifying a co-located host, a peering point, or a facility operator who can be served — is a separate dataset entirely.

What the graph does. The physical layer is pre-joined. Walk from the ASN to the buildings and IXPs it's present at.

cypher · runnablegraph.whisper.securitySign in to run
// Where the announcing network physically sits
MATCH (a:ASN {name: "AS60729"})
OPTIONAL MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
OPTIONAL MATCH (a)-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
RETURN a.name AS asn,
       collect(DISTINCT f.name) AS facilities,
       collect(DISTINCT ix.name) AS exchanges
LIMIT 5

Facility names are concrete (Equinix DA1 - Dallas), which makes them usable directly in a subpoena to the colocation provider. Both legs are OPTIONAL, so the row comes back even when the lists are empty — and for the network above they are: a small operator with no published facility or exchange presence is itself a documentable fact. Run the same query on a large transit network to see the populated shape.

Ownership: the full registration record

Complete WHOIS ownership chain

Why it's hard with flat tools. A single WHOIS query returns the current registrar and contacts. The chain of who held the domain before — the registrar transfers that often track a change of control — is scattered across historical lookups.

What the graph does. Current and prior registrars, contact emails, phones, and registrant org sit on the hostname as edges. One query documents the whole record.

cypher · runnablegraph.whisper.securitySign in to run
// Complete publicly available registration record for a domain
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (h)-[:PREV_REGISTRAR]->(pr:REGISTRAR)
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)
OPTIONAL MATCH (h)-[:HAS_PHONE]->(p:PHONE)
OPTIONAL MATCH (h)-[:REGISTERED_BY]->(org:ORGANIZATION)
RETURN h.name AS domain,
       collect(DISTINCT r.name)  AS current_registrar,
       collect(DISTINCT pr.name) AS previous_registrars,
       collect(DISTINCT e.name)  AS emails,
       collect(DISTINCT p.name)  AS phones,
       collect(DISTINCT org.name) AS organizations
LIMIT 5

json
[{
  "domain": "paypal.com",
  "current_registrar": ["iana:292"],
  "previous_registrars": ["registrar:markmonitor inc.", "iana:292"],
  "emails": ["hostmaster@ebay.com", "hostmaster@paypal.com", "service@sintl-paypal.com"],
  "phones": ["+14083767400", "+18882211161"],
  "organizations": ["domain administrator", "host master", "paypal"]
}]

PREV_REGISTRAR is the historical registrar chain — document when a domain changed hands. Registrar ids use the iana:NNNN form, resolvable at the IANA registrar database. Phone numbers are E.164 when available. Register-redaction (privacy-proxy) values like data-protected.net are themselves a documentable fact: note that the registrant elected privacy as of the capture date. Raw registrant strings (host master, paypal) are stored as the registrar wrote them; (org)-[:SAME_ORG_AS]->(:ORGANIZATION) folds the variants to the canonical company name where the graph has mapped one.

Timestamped registration history (the dated evidence)

Why it matters. "The domain was registered to X" is weak. "WHOIS captured 2023-08-14 shows registrant X; the prior snapshot 2021-02-03 shows registrant Y" is defensible. whisper.history.whois returns dated WHOIS snapshots (RDAP/WHOIS-sourced) for a domain, one row per snapshot, with a fixed column set you can cite exhibit after exhibit.

cypher · runnablegraph.whisper.securitySign in to run
// Dated WHOIS snapshots — one row per snapshot, stable columns
CALL whisper.history.whois("paypal.com")
YIELD indicator, queryTime, createDate, updateDate, expiryDate, registrar, registrant, nameServers
RETURN indicator, queryTime, createDate, updateDate, expiryDate, registrar, registrant, nameServers
LIMIT 10

Each row carries its own snapshot timestamp (queryTime) — cite the full timestamp and the registry/feed source. A subdomain folds up to its registrable parent, and the registrableDomain column names the parent the lookup resolved to, so an exhibit can state exactly which registration record it shows. For an IP, ASN or prefix the routing variant whisper.history.bgp returns dated origin history (origin, prefix, startTime, endTime, visibility) so you can show which ASN announced an address on a given date — directly relevant when an offense maps to a specific time window. Keep a LIMIT on it and expect a longer round trip for a large network.

Pivot via shared registrant email

Why it's hard with flat tools. Reverse-WHOIS by email is a siloed feature on most platforms, and it doesn't join to anything else you know.

What the graph does. A contact email is a shared node — every domain that ever listed it hangs off it by one edge. Walk the email back out to its siblings.

cypher · runnablegraph.whisper.securitySign in to run
// Domains sharing a contact email with the anchor domain
MATCH (h1:HOSTNAME {name: "paypal.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH e LIMIT 25
MATCH (e)<-[:HAS_EMAIL]-(h2:HOSTNAME)
RETURN e.name AS contact_email, collect(DISTINCT h2.name)[..50] AS related_domains
LIMIT 25

Document the pivot precisely: "paypal.com and <domain> both list registrant email <X> in WHOIS, captured <date>." Be cautious with registrar/privacy-proxy emails (e.g. *@markmonitor.com, *@data-protected.net) — they're shared by thousands of unrelated domains and are not evidence of common control. Bind the high-fan-out side with WITH ... LIMIT, as above.

Pivot via shared registrant organization

cypher · runnablegraph.whisper.securitySign in to run
// Other domains registered to the same organization.
// Bound the per-org fan-out in a subquery — a registrar-scale org can
// own millions of domains, so cap each branch before collecting.
MATCH (h1:HOSTNAME {name: "paypal.com"})-[:REGISTERED_BY]->(org:ORGANIZATION)
CALL {
  WITH org
  MATCH (org)<-[:REGISTERED_BY]-(h2:HOSTNAME)
  RETURN h2.name AS related LIMIT 50
}
RETURN org.name AS organization, collect(DISTINCT related) AS related_domains
LIMIT 10

A generic registrant string such as domain administrator is shared by unrelated domains across the whole internet and proves nothing on its own; a distinctive company string does. Check (org)-[:SAME_ORG_AS]->(:ORGANIZATION) for the canonical company before you treat two spellings as two owners.

Pivot via shared hosting (co-tenancy)

Why it matters. Shared registrant proves a paperwork link; shared IP proves an operational one. Both, dated, are stronger than either alone.

cypher · runnablegraph.whisper.securitySign in to run
// Other hostnames resolving to the same IP as the target
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
RETURN ip.name AS shared_ip, collect(DISTINCT sibling.name)[..50] AS co_tenants
LIMIT 10

Note the direction: RESOLVES_TO is HOSTNAME→IP, so co-tenants come back via (ip)<-[:RESOLVES_TO]-(sibling). On a shared-hosting or CDN IP, co-tenancy is weak evidence of a relationship — qualify it. On a dedicated host it's strong. CALL whisper.identify(["paypal.com"]) tells you which case you're in: its host_class field separates dedicated hosts from multi-tenant platforms, cloud, and CDN space.

Typosquats and lookalikes

Why it matters. Phishing and fraud cases turn on lookalike domains. Generating them by hand misses homoglyph and bitsquat variants; checking which are actually registered is a second pass.

cypher · runnablegraph.whisper.securitySign in to run
// Registered lookalikes of the brand, with the algorithm that generated each
CALL whisper.variants("paypal.com")

By default whisper.variants returns only variants that exist as nodes — registered, not necessarily malicious. Pivot each hit through explain() for a verdict (next section) before characterizing it. For the full brand workflow, including the phishing-kit fingerprints that tie disposable domains to one operator, see Lookalike Hunting.

Anonymity infrastructure & threat verdict

Tor-exit identity that survives IP rotation

Why it's hard with flat tools. A "this IP is a Tor exit" boolean tells you nothing about the relay's stable identity. Operators rotate IPs; the relay fingerprint persists.

What the graph does. An IP links to the Tor relay it operates, keyed by fingerprint — a stable identifier you can track across address changes and cite as the relay's identity.

cypher · runnablegraph.whisper.securitySign in to run
// Is this IP a Tor exit, and what is the relay's stable identity?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip, relay.name AS relay_fingerprint, ip.isTor AS flagged_tor
LIMIT 5

The relay fingerprint is the durable identity; the IP is just where it ran at observation time. A single IP can operate several relay fingerprints, so expect multiple rows; CALL whisper.lookupTorRelay("185.220.101.1") returns the fuller relay record. Combine with whisper.history.bgp on the IP to show the BGP origin on the offense date.

Sourced threat verdict with evidence

Why it matters. An affidavit needs the basis for calling an indicator malicious, not just a label. explain returns a reconciled, blocking-aware score with the contributing feeds, factors, and first/last-seen dates — an inspectable evidence chain.

cypher · runnablegraph.whisper.securitySign in to run
// Scored verdict + the exact feeds, factors and timestamps behind it
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources

Name the columns for the exhibit. A bare CALL explain(...) hands back the procedure's full column set, and any column that carries nothing for this indicator comes back blank next to the ones that do — a blank cell in an exhibit invites a question you do not want to answer on the stand. YIELD the columns you are citing: factors is the arithmetic, sources is the feed-by-feed provenance with first/last-seen dates. To cite only the feeds that moved the score, UNWIND sources AS s and keep WHERE s.weight >= 1.0.

You can also read the reconciled verdict and category flags straight off a node when you've already traversed to it:

cypher · runnablegraph.whisper.securitySign in to run
// Reconciled verdict + flags carried on the node
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.name AS ip,
       ip.verdictScore AS score,
       ip.verdictLevel AS level,
       ip.verdictBlocking AS blocking,
       ip.isTor AS isTor,
       ip.isC2 AS isC2,
       ip.isAnonymizer AS isAnonymizer
LIMIT 1

Prefer verdictScore / verdictLevel (reconciled across feeds) over any single-source score. The flags (isC2, isMalware, isTor, isAnonymizer, …) let you characterize what kind of bad — useful for matching infrastructure to a specific offense. For what each feed covers, see Threat Feeds & Categories.

Dark-web infrastructure

Which dark-web services has the graph observed, and are they live?

A case file references an onion address and you want it resolved inside the same graph as everything else: whether it has been observed live, on which network, and what verdict it carries. Liveness at the time you looked is the fact you will be asked about, so timestamp it.

cypher · runnablegraph.whisper.securitySign in to run
// Observed dark-web domains, with liveness and verdict
MATCH (d:DWI_DOMAIN)
RETURN d.name AS onion_domain, d.dwi_network AS network,
       d.dwi_http_status AS http_status, d.verdictLevel AS verdict
LIMIT 10

Returns: onion_domain, network, http_status, verdict

Sample output (captured 2026-09-02):

json
[
  {"onion_domain": "darkmmnjhxn5sf3j2rz3hy36kdotf3apgfh4g6iez6cb2q2feazlsuad.onion", "network": "onion", "http_status": 200, "verdict": "MEDIUM"},
  {"onion_domain": "darkmmaugjlnyv7i367vwddz4jkvy2sdlaeutb2uilgs5g3no54mb4qd.onion", "network": "onion", "http_status": 200, "verdict": "MEDIUM"}
]

Costs: a bounded browse over a small catalogue, no traversal; to check one address rather than browse, anchor it — MATCH (d:DWI_DOMAIN {name: "<address>.onion"}) RETURN d.dwi_http_status, d.dwi_final_url, d.dwi_last_event_at, d.dwi_is_challenge_page LIMIT 1 — and read an empty result as "not observed", never as "not live".

dwi_http_status is the last observed response — a 200 means the service answered when it was last checked, which for an onion service is a perishable fact worth timestamping against dwi_last_event_at. dwi_final_url records where the request ended up after redirects. dwi_is_challenge_page flags a response that was really an anti-crawling interstitial rather than the site itself, which matters if you are about to assert the content of a page in a filing.

From here, → Sourced threat verdict with evidence for the feeds behind the verdict column, then the attribution packet below for anything the service resolves to on the clear web.

Putting it together: a defensible attribution packet

A single pass that produces network owner, jurisdiction, anonymity status, and verdict — the spine of an evidence exhibit.

cypher · runnablegraph.whisper.securitySign in to run
// Attribution packet for one IP: owner, country, Tor status, verdict
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(country:COUNTRY)
OPTIONAL MATCH (ip)-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip,
       ap.name AS announced_prefix,
       a.name AS asn,
       n.name AS network_operator,
       city.name AS city,
       country.name AS country,
       relay.name AS tor_relay,
       ip.verdictLevel AS verdict,
       ip.verdictBlocking AS blocking
LIMIT 5

json
[{"ip": "185.220.101.1", "announced_prefix": "185.220.101.0/24", "asn": "AS60729", "network_operator": "TORSERVERS-NET - Stiftung Erneuerbare Freiheit", "city": "Brandenburg, DE", "country": "DE", "tor_relay": "6c64100d8f7050e76f420ce404031eabc7101124", "verdict": "LOW", "blocking": false}]

Then attach the dated routing history and the scored evidence chain:

cypher · runnablegraph.whisper.securitySign in to run
// Dated BGP origin history for the offense window — stable routing columns
CALL whisper.history.bgp("185.220.101.1")
YIELD origin, prefix, startTime, endTime, visibility
RETURN origin, prefix, startTime, endTime, visibility
LIMIT 10

cypher · runnablegraph.whisper.securitySign in to run
// reconciled verdict + contributing feeds & timestamps
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources

Capture all three results with the run timestamp. Together they answer who (network operator + registrant), where (jurisdiction + physical facility), when (dated WHOIS/BGP snapshots), and what (reconciled verdict with sourced feeds) — each backed by a specific graph edge rather than an analyst's recollection. The packet returns one row per Tor relay the address operates, so expect several near-identical rows when the IP runs more than one.

Where to go next

  • Graph Schema — every label, edge, and property. Watch the direction landmines: RESOLVES_TO is host→IP, NAMESERVER_FOR/MAIL_FOR are server→domain, CHILD_OF is child→parent.
  • Procedures — signatures for explain, whisper.history.whois / whisper.history.bgp, whisper.variants, and whisper.origins, plus the whisper.identify/whisper.assess/whisper.walk host-context calls.
  • Campaign Pivoting — the actor and ATT&CK reference layer, alias resolution, and the sparse attribution and malware-tag edges.
  • Internet Measurement — the bulk and aggregate side of research queries: topology surveys and longitudinal studies.
  • Cross-Layer Patterns — the reusable multi-layer pivots behind every recipe here.
  • Threat Feeds & Categories — the 134 feeds and 32 categories behind the verdicts.
  • AI & Agents — point an MCP client at the graph (setup); the query and explain_indicator tools let an assistant build the attribution packet above in-conversation, every claim citing a graph edge.