# Helpers — Naming & Lookups

> WhisperGraph helper procedures for naming and lookups: public suffixes, ASN rankings and countries, Tor relays, TLS fingerprints, search, CVEs, export.

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

---
Beyond the investigation and identity procedures, WhisperGraph ships a set of smaller utilities — the naming, ranking, lookup and export calls you reach for inside a larger query. None of them need a deep traversal; each answers one focused question. Every `YIELD` column named below came from a live call on 2026-09-02, so you can write the `YIELD` clause without guessing. Quote every argument, and mind the types: the ranking procedures take an Integer, and `whisper.export` takes one map.

## Public-suffix functions

The PSL functions apply the Public Suffix List so you can find the registrable apex of a hostname or test whether a label is itself a public suffix. They are the reliable way to reduce a messy hostname to the domain you actually want to anchor on.

```cypher expect=rows>0 seed=mail.google.co.uk verified=2026-09-02
CALL whisper.psl.tldPlusOne("mail.google.co.uk") YIELD apex RETURN apex
```

| Function | Argument | Yields | Returns |
|----------|----------|--------|---------|
| `whisper.psl.tldPlusOne(host)` | a hostname | `apex` | the registrable apex (`mail.google.co.uk` → `google.co.uk`) |
| `whisper.psl.isPublicSuffix(label)` | a label | `result` | `true` if the label is itself a public suffix |
| `whisper.psl.affiliation(host)` | exactly one host | `found`, `suffix`, `submitterLogin`, `submitterOrg`, `evidenceKind`, `confidence` | the affiliation group for the host; `found: false` is a populated row |

`whisper.psl.affiliation()` takes **exactly one** argument. Use `tldPlusOne` before an anchored lookup when your input might be a subdomain — anchoring on the apex is usually what you want.

## Network rankings

A few calls answer shape-of-the-internet questions from precomputed snapshots, without scanning the `ASN` label. The ranking arguments are Integers, not strings.

```cypher expect=rows>0 verified=2026-09-02
CALL whisper.topAsnsByPrefixCount(10) YIELD asn, prefixCount
RETURN asn, prefixCount ORDER BY prefixCount DESC
```

```cypher expect=rows>0 verified=2026-09-02
CALL whisper.asnCountries(10) YIELD country, asns RETURN country, asns
```

| Procedure | Argument | Yields | Answers |
|-----------|----------|--------|---------|
| `whisper.topAsnsByPrefixCount(n)` | Integer | `asn`, `prefixCount` | the networks announcing the most prefixes |
| `whisper.asnCountries(n)` | Integer | `country`, `asns` | ASN count per registration country, largest first |
| `whisper.bgpDegreeDistribution()` | none | `inDegree`, `outDegree`, `asnCount` | the peering graph as a histogram, one row per degree pair |
| `whisper.asnThreatDensity(asn)` | a string, `"AS13335"` | `asn`, `listedIps`, `announcedIpv4`, `densityRatio`, `routedPrefixes`, `coverage` | listed addresses against announced space for one network |
| `whisper.asSet(name)` | a string, an IRR as-set name | `asSetName`, `memberAsn`, `sourceRir` | the member ASNs of an IRR as-set |

## Tor & TLS-fingerprint lookups

Two direct lookups return the full record behind a Tor exit IP or a known TLS fingerprint, without composing the underlying edges yourself.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
CALL whisper.lookupTorRelay("185.220.101.1") YIELD indicator, found
RETURN indicator, found
```

| Procedure | Argument | Yields | Answers |
|-----------|----------|--------|---------|
| `whisper.lookupTorRelay(ip)` | an exit IP, or a relay fingerprint | `indicator`, `found`, `fingerprint`, `exitAddresses`, `exitAddressCount`, `exitAddressesV6`, `exitAddressCountV6`, `source`, `ingestedAt` | the Tor relay record behind an exit address |
| `whisper.lookupTlsFingerprint(fingerprint)` | a bare hash, or `kind:hash` such as `ja3:…` or `jarm:…`; a bare hash is probed across every fingerprint kind | `indicator`, `found`, `kind`, `hash`, `category`, `label`, `family`, `vendor`, `client`, `trustTier`, `sourceCount`, `firstSeen`, `lastSeen`, `licensePosture` | the record for that TLS fingerprint |

Both return `found: false` rather than zero rows when they have nothing, so the absence is always a populated row you can branch on. A hostname passed to the fingerprint lookup also reads `found: false`.

> **Expect `found: false` for most fingerprints.** It means Whisper holds no observation of that fingerprint, not that the host shares no infrastructure.

## whisper.search(token[, options])

When a token arrives unclassified — it could be an IP, hostname, ASN, CIDR, prefix, or suffix — `whisper.search` runs a bounded, type-aware lookup instead of an unanchored scan. It works out what the token is and looks it up as that node type, so it stays fast where a label-wide scan would run away.

```cypher expect=rows>0,no-null-columns seed=185.220.101.1 verified=2026-09-02
CALL whisper.search("185.220.101.1")
YIELD query, kind, name, matchedField, matchType
RETURN query, kind, name, matchedField, matchType
```

`kind` is the label it decided on (`IPV4` here; `HOSTNAME`, `ASN`, and `PREFIX` are the others you will see), and `matchType` says how the token was matched: `exact` for an indexed hit, `prefix` or `suffix` for a bounded scan, `unsupported` for a token with no typed route. The full column set is `query, kind, name, matchedField, matchType, warning, score`. `warning` stays `null` on an exact hit and fills in when the lookup had to widen or stop early (`prefix_expansion`, `suffix_too_broad`, `no_typed_route`, `deadline`), so yield it whenever the input is not already typed.

The optional second argument is an options map: `types` restricts the labels searched, `mode` is `auto`, `exact`, `prefix` or `suffix`, `suffix` anchors a hostname hunt under a narrow suffix, `limit` bounds the rows and `timeoutMs` bounds the time. A suffix hunt needs a specific multi-label suffix such as `.gov.au`; a broad one returns a single `unsupported` row with `warning: "suffix_too_broad"` rather than scanning.

```cypher expect=rows>0 seed=.gov.au verified=2026-09-02
CALL whisper.search("vic", {types: ["HOSTNAME"], suffix: ".gov.au", limit: 25})
YIELD name, matchType, warning
RETURN name, matchType, warning
```

Once you know what the token is, anchor on it directly — `whisper.search` is the entry point, not the traversal.

## whisper.resolve(host) and dangling CNAMEs

`whisper.resolve` returns a host's current A and AAAA records from passive data, with a `coverage` value saying whether anything resolved. `whisper.danglingCname` takes a host or a list and returns one row per CNAME whose target apex is in a state worth acting on; a clean host returns no rows, and `target_apex_state: UNREGISTERED` is the subdomain-takeover signal.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
CALL whisper.resolve("cloudflare.com") YIELD host, a, aaaa, coverage
RETURN host, a, aaaa, coverage
```

```cypher expect=static verified=2026-09-02
CALL whisper.danglingCname(["github.io", "s3.amazonaws.com"])
YIELD host, target, target_apex, target_apex_state, observed_at
RETURN host, target, target_apex, target_apex_state, observed_at
```

Captured 2026-09-02: both of those hosts are clean, so the second call returns no rows. That is the expected answer, not a failure. `whisper.resolve` yields `host, a, aaaa, freshest_observation_ms, coverage`; `whisper.danglingCname` yields `host, target, target_apex, target_apex_state, observed_at`.

## CVE plane

Two procedures answer vulnerability questions without a scan. `whisper.cve.byPackage` takes **one full CPE 2.3 string**, wildcards and all, and returns one row per known CVE, ranked. `epss` estimates real-world exploitation probability, and `kev: true` means a known-exploited list carries it, which beats any score. A spec it cannot read, such as a Package URL, still returns a row: `coverage: "unsupported-spec"` with every other column null, so check `coverage` first, every time.

```cypher expect=rows>0 seed=openssl verified=2026-09-02
CALL whisper.cve.byPackage("cpe:2.3:a:openssl:openssl:3.0.0:*:*:*:*:*:*:*")
YIELD cve, band, kev, ransomware, epss, cvss, coverage
RETURN cve, band, kev, ransomware, epss, cvss, coverage
LIMIT 10
```

`whisper.vulnPosture` rolls exposure up into exactly one row. It accepts a hostname, an ASN, or a map with a `cves` list, `packages` (`{name, version, ecosystem}`) or `cpes`. Read `coverage` before the counts: when it reads *partial*, the graph holds some software identification for the target but not a complete inventory, so the counts are a floor, not an assessment; `full` means the whole input was scored. `kevCount` and `ransomwareCount` are the columns that change a conversation.

```cypher expect=rows>0,no-null-columns seed=CVE-2021-44228 verified=2026-09-02
CALL whisper.vulnPosture({cves: ["CVE-2021-44228", "CVE-2014-0160"]})
YIELD openCveCount, critical, high, kevCount, ransomwareCount, maxEpss, maxCvss, priority, coverage
RETURN openCveCount, critical, high, kevCount, ransomwareCount, maxEpss, maxCvss, priority, coverage
```

The full column set is `openCveCount, scoredCount, critical, high, medium, low, kevCount, ransomwareCount, maxEpss, maxCvss, priority, coverage`; `priority` lists the CVEs that most deserve attention, each with its band, KEV and ransomware flags, EPSS and CVSS. Treat the whole result as external, passive evidence to start a conversation, never as a substitute for an authenticated scan.

## Threat-intel snapshot candidates

Three precomputed lists surface infrastructure that inflates co-tenancy: apexes that behave like CDNs or multi-tenant platforms, and IPs that host many unrelated names. Each takes an Integer and returns the top candidates with a `recommendation`; zero rows means the snapshot currently holds no candidates of that class.

```cypher expect=rows>0 verified=2026-09-02
CALL whisper.threatIntel.candidateCdnApex(5)
YIELD apex, subCount, certCount, wildcardCount, recommendation
RETURN apex, subCount, certCount, wildcardCount, recommendation
```

| Procedure | Yields |
|-----------|--------|
| `whisper.threatIntel.candidateCdnApex(n)` | `apex`, `subCount`, `certCount`, `wildcardCount`, `isOnPslPrivate`, `recommendation`, `computedAt` |
| `whisper.threatIntel.candidateMultiTenantApex(n)` | `name`, `nodeId`, `subCount`, `threatSources`, `threatScore`, `isOnDenyList`, `recommendation`, `computedAt` |
| `whisper.threatIntel.candidateSharedHostingIp(n)` | `ip`, `nodeId`, `hostCount`, `threatSources`, `threatScore`, `isAlreadyMarked`, `recommendation`, `computedAt` |

## Bulk export

`whisper.export` hands out the threat corpus by label for offline use. It takes **exactly one map** with the keys `label`, `limit` and `cursor`: `label` is required and is one of `malicious`, `ambiguous` or `benign-allowlisted`. Always pass `limit`, and page by feeding a row's opaque `next_cursor` back in as `cursor`.

```cypher expect=rows>0 verified=2026-09-02
CALL whisper.export({label: "benign-allowlisted", limit: 5})
YIELD host, label, ip, asn, coverage, last_seen, next_cursor
RETURN host, label, ip, asn, coverage, last_seen, next_cursor
```

The full row is `host, label, ip, cidr, asn, url_paths, cert_shas, tls_fingerprints, dns, last_seen, coverage, truncated, supersedes, look_alike_negatives, next_cursor, company_apex, company_linkage, pl_generation`. The [bulk export guide](/docs/guides/bulk-export) covers the end-to-end workflow.

## whisper.version()

The liveness check. It reports the build answering your request, and it is the first thing to run when the graph is behaving oddly — a version string proves you reached the engine rather than something in front of it.

```cypher expect=rows>0,no-null-columns verified=2026-09-02
CALL whisper.version() YIELD version, buildTime RETURN version, buildTime
```

Quote the `version` when you report a problem: the first question anyone will ask is which build you hit. `CALL whisper.quota()` is the companion call. It returns `key` and `value` rows describing your own service context — who the server takes you for, and whether it recognised your key — and it is the first thing to check when a call behaves as if you were not signed in.

## Schema introspection

The `db.*` calls describe the live schema, and they are cheap — they answer immediately: `db.labels()`, `db.relationshipTypes()` (the column is `type`, not `relationshipType`), `db.propertyKeys()`, and `db.schema()`. `db.schema()` also accepts a format argument — `db.schema("json")`, `db.schema("markdown")`, or `db.schema("details")` — and `db.schema.nodeTypeProperties()` / `db.schema.relTypeProperties()` list the properties on each label and edge type. `db.functions()` and `db.procedures()` list the callable surface itself. They are covered on the [Graph Schema](/docs/whisper-graph/schema) pages and in the [Procedures overview](/docs/whisper-graph/procedures).
