Skip to content
Recipes
Skip navigation
Recipes

Third-Party & Portfolio Posture

Cypher for the two jobs that read a vendor from the outside: cyber-insurance risk scoring and compliance evidence an auditor can verify, from registration and routing to physical concentration and known vulnerability exposure.

Published

View as Markdown
On this page (36)

Third-Party & Portfolio Posture Documentation

You have a vendor's domain and a questionnaire they filled in themselves. These recipes take you to the two artefacts built from the same passive data: the underwriting inputs behind a cyber-insurance risk score, and the registration, jurisdiction and sanctions evidence a compliance or audit file has to survive on. Nothing here touches the vendor's systems, and every claim resolves to a graph edge you can cite.

Everything runs against https://graph.whisper.security/api/query, read-only, anchored on an indexed name. Copy, paste, swap the anchor. First call and keys: Getting Started. Field model: Graph Schema.

Key concepts: WHOIS · RDAP · SPF · DMARC · RPKI ROA · Reconciled verdict · Concentration risk · Supply-chain risk.

Some need an account. The jurisdiction, WHOIS-history and full-profile recipes cross more layers than the signed-out path runs, so sign in to run them. Pass the key in the X-API-Key header.

Run it live: Digital Infrastructure Mapping and Supply-Chain Dependency Mapping run these pivots as guided investigations on your own vendor.

Four things that bite, once, on every page below. NAMESERVER_FOR and MAIL_FOR point server → domain, so those hops are written backwards. BELONGS_TO reaches the allocated PREFIX and ANNOUNCED_BY reaches the BGP-announced one; ROA_AUTHORIZES_PREFIX and PREFIX_IN_REGION hang off the allocated prefix, while DELEGATED_TO (the operating vendor) is read off the address. Prefer verdictScore / verdictLevel / verdictBlocking over the older threatScore / threatLevel; both families are populated, so never mix them inside one rule. And every query anchors on a name and reaches FEED_SOURCE or CT_OBSERVATION through its edge.

Registration and ownership

What does the registration record actually say?

Registrar, nameservers, mail and SPF are four lookups you normally stitch together by hand. One anchored query with an OPTIONAL MATCH per field returns the whole surface: the raw material for a hygiene score and the evidence pack in one row.

cypher · runnablegraph.whisper.securitySign in to run
// Infrastructure overview: registrar, nameservers, mail servers, SPF includes
MATCH (h:HOSTNAME {name: "stripe.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h)
OPTIONAL MATCH (mx:HOSTNAME)-[:MAIL_FOR]->(h)
OPTIONAL MATCH (h)-[:SPF_INCLUDE]->(spf:HOSTNAME)
RETURN h.name AS domain,
       collect(DISTINCT r.name)  AS registrar,
       collect(DISTINCT ns.name) AS nameservers,
       collect(DISTINCT mx.name) AS mailservers,
       count(DISTINCT spf)       AS spf_includes
LIMIT 1

Returns: domain, registrar, nameservers, mailservers, spf_includes

json
[{
  "domain": "stripe.com",
  "registrar": ["iana:447"],
  "nameservers": ["ns-423.awsdns-52.com", "ns-705.awsdns-24.net", "ns-1087.awsdns-07.org", "ns-1882.awsdns-43.co.uk"],
  "mailservers": ["aspmx.l.google.com", "alt1.aspmx.l.google.com", "alt2.aspmx.l.google.com"],
  "spf_includes": 3
}]

Costs: milliseconds; one indexed anchor, four optional single hops, no traversal past it; the nameserver list is trimmed above, passive data can also carry stale delegations.

Every field takes OPTIONAL MATCH: one mandatory MATCH on a missing WHOIS field drops the whole row, which is how an evidence pack loses a vendor. Registrar ids use the iana:NNNN form, where NNNN is the IANA registrar id; resolve it at the IANA database. Add (h)-[:REGISTERED_BY]->(:ORGANIZATION) for the registrant and (h)-[:PREV_REGISTRAR]->(:REGISTRAR) to catch a transfer, which is a control change worth flagging by itself.

Read nameservers twice: recognized cloud DNS (Route 53, Azure DNS, Google Cloud DNS) is a hygiene signal; a single provider with no secondary is a resilience finding. spf_includes above zero means an email authorization policy is published at all. Concentration in the physical layer is its own section below.

From here, → Who registered it, and what else do they own?, or When did the registrar or nameservers last change? when a transfer near a renewal needs dating.

Who registered it, and what else do they own?

Confirming a registrant normally means reading free-text WHOIS org fields and hoping they are spelled consistently. Registrant organizations are nodes, so you verify identity in one hop and fold in the entities already reconciled to the same owner.

cypher · runnablegraph.whisper.securitySign in to run
// Registrant organization(s) for a domain, with reconciled aliases
MATCH (h:HOSTNAME {name: "stripe.com"})-[:REGISTERED_BY]->(org:ORGANIZATION)
OPTIONAL MATCH (org)-[:SAME_ORG_AS]->(alias:ORGANIZATION)
RETURN h.name AS domain,
       collect(DISTINCT org.name) AS registrant_orgs,
       collect(DISTINCT alias.name) AS reconciled_aliases
LIMIT 5

Returns: domain, registrant_orgs, reconciled_aliases

json
[{
  "domain": "stripe.com",
  "registrant_orgs": ["domain admin", "stripe"],
  "reconciled_aliases": []
}]

Costs: milliseconds; one indexed anchor plus a single hop each way; reach ORGANIZATION through the edge, never by anchoring on its name.

WHOIS carries several org entries (registrant, admin, technical), so collect(DISTINCT …) folds them into one evidence line. Privacy redaction covers a large share of current WHOIS: a missing registrant is withheld, never none.

RDAP is not the path. RDAP_ENTITY records carry no edge of any type, so none is reachable from a domain or citable as provenance. Registration runs through REGISTERED_BYORGANIZATION, HAS_EMAIL, HAS_PHONE and HAS_REGISTRAR.

From here, → When did the registrar or nameservers last change?.

When did the registrar or nameservers last change?

WHOIS shows you now. Proving when control moved (a transfer, a nameserver swap, the creation date of a domain that appeared last week) needs an archive you probably do not keep. whisper.history.whois is the WHOIS-only shape with stable columns.

cypher · runnablegraph.whisper.securitySign in to run
// Timestamped WHOIS snapshots for evidence collection
CALL whisper.history.whois("google.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 5

Returns: createDate, updateDate, registrar, registrant, nameServers

json
[
  {"createDate": "1997-09-05", "updateDate": "2024-08-02", "registrar": "MarkMonitor, Inc.", "registrant": "Google LLC", "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com"},
  {"createDate": "1997-09-15", "updateDate": "2015-06-12", "registrar": "MarkMonitor, Inc.", "registrant": "Google Inc.", "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com"}
]

Costs: a procedure call, not a traversal; one row per snapshot, oldest records often un-redacted.

whisper.history.whois() requires an API key and does not run signed out. It folds a URL to its host and a subdomain to its apex. For an IP, ASN or prefix, whisper.history.bgp() returns routing history with its own stable columns. Gaps are normal; record the snapshot's own timestamp as the evidence date, never "today". Signature.

From here, → Who hosts this vendor, and is that network clean?.

Hosting attribution and network reputation

Who hosts this vendor, and is that network clean?

"Who hosts them" is address → announced prefix → ASN → network name, and the prefix-to-ASN join is the step a DNS tool cannot make. Chaining it to the AS reputation rollup answers attribution and hygiene in one round-trip.

cypher · runnablegraph.whisper.securitySign in to run
// Vendor domain -> hosting ASN -> AS reputation, in one traversal
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
      -[:HAS_NAME]->(n:ASN_NAME)
RETURN DISTINCT n.name AS network,
       a.name AS asn,
       a.overallThreatLevel     AS as_threat_level,
       a.hasThreateningPrefixes AS as_has_bad_prefixes
LIMIT 5

Returns: network, asn, as_threat_level, as_has_bad_prefixes

json
[{"network": "CLOUDFLARENET - Cloudflare, Inc.", "asn": "AS13335", "as_threat_level": "NONE", "as_has_bad_prefixes": true}]

Costs: milliseconds; four explicit single hops from an indexed anchor; the AS reputation is precomputed on the ASN node, so nothing scans the prefixes it routes.

This turns "they use AWS" into an attribution plus a hygiene grade in one cell. Large organizations sit across two to five ASNs (CDN, cloud, legacy data centre), so several providers is a resilience signal, one small hosting AS behind a material vendor is a follow-up question, and hasThreateningPrefixes = true on its own does not make a vendor compromised.

When you already hold the ASN, anchor it directly (MATCH (a:ASN {name: "AS13335"}); names are unique) for the same columns plus maxThreatScore, avgThreatScore and the routing posture (hijackPostureScore, routeLeakCount). Add OPTIONAL MATCH (a)-[:HAS_COUNTRY]->(co:COUNTRY) for the operator's registered home jurisdiction, or explain("AS13335") for the reasoning.

From here, → Who actually runs this, behind the AS and the CDN? when the AS is not the operator.

Who actually runs this, behind the AS and the CDN?

An address belongs to an AS, but the AS is often not the operator: the address sits in a range a cloud or SaaS vendor publishes as its own. DELEGATED_TO maps the address to the VENDOR that operates it, which is how a self-hosting claim meets ground truth.

cypher · runnablegraph.whisper.securitySign in to run
// Who actually operates the address space a vendor resolves into?
MATCH (h:HOSTNAME {name: "netflix.com"})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name AS ip, collect(DISTINCT v.displayName) AS operated_by
LIMIT 10

Returns: ip, operated_by

json
[
  {"ip": "18.200.8.190", "operated_by": ["Aws"]},
  {"ip": "3.251.50.149", "operated_by": ["Aws"]},
  {"ip": "44.240.158.19", "operated_by": ["Aws"]}
]

Costs: milliseconds; anchored fan-out over resolving addresses, then one optional hop each; keep it optional, most addresses are not in a published vendor range and return an empty list.

If the questionnaire says "on-premises, self-managed" and operated_by returns ["Aws"], you have a concrete discrepancy to raise rather than a suspicion. An empty list is not in a published range, not self-hosted.

A CDN hides the origin as well, so run whisper.origins() before trusting any attribution for a CDN-fronted domain; the origin's AS reputation, not the CDN's, reflects the vendor's own posture.

cypher · runnablegraph.whisper.securitySign in to run
CALL whisper.origins("cloudflare.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
ORDER BY confidence DESC
LIMIT 10

json
[{"ip": "100.21.79.143", "confidence": 0.4499, "methods": ["sibling"], "asnName": "AMAZON-02 - Amazon.com, Inc."}]

methods names how each origin candidate was found (mx, spf, sibling hosts, leaked links), and a candidate corroborated by several of them scores highest. Details: whisper.origins().

From here, → Which countries can this traffic land in?.

Jurisdiction and data residency

Which countries can this traffic land in?

"What countries does this vendor's infrastructure touch" needs DNS resolution, a GeoIP lookup per address and a dedup pass. One anchored traversal walks host → address → city → country and counts the exposure.

cypher · runnablegraph.whisper.securitySign in to run
// Countries a domain's resolved IPs geolocate to
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH DISTINCT ip LIMIT 200
MATCH (ip)-[:LOCATED_IN]->(:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
RETURN co.name AS country, count(DISTINCT ip) AS ip_count
ORDER BY ip_count DESC
LIMIT 25

Returns: country, ip_count

json
[{"country": "CA", "ip_count": 2}]

Costs: milliseconds; anchored fan-out staged with WITH DISTINCT, so the geo hops run over a bounded set; anycast addresses often carry no city edge at all.

This is the GeoIP location of resolved addresses, not the legal seat of the operator: two questions a residency review needs on the record separately, and citing one as the other is the mistake this recipe prevents. Anycast and large-CDN addresses serve many regions from one address. Read the list as where traffic can land, then corroborate with the ASN's own HAS_COUNTRY.

From here, → Is it in the cloud region the contract names?.

Is it in the cloud region the contract names?

"Is this in eu-west-1, or did it drift to a US region" is not answerable from DNS at all, and it is the form of the question a contract actually uses.

cypher · runnablegraph.whisper.securitySign in to run
// Cloud region(s) a domain's hosting prefixes sit in
MATCH (h:HOSTNAME {name: "netflix.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH DISTINCT ip LIMIT 50
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)-[:PREFIX_IN_REGION]->(reg:CLOUD_REGION)
RETURN DISTINCT reg.name AS cloud_region, count(DISTINCT ip) AS ip_count
ORDER BY ip_count DESC
LIMIT 25

Returns: cloud_region, ip_count

json
[{"cloud_region": "aws:eu-west-1", "ip_count": 5}, {"cloud_region": "aws:us-west-2", "ip_count": 1}]

Costs: milliseconds; staged fan-out plus two single hops; anchor the region edge on the allocated prefix, never on the announcing network. Coverage on this plane is partial.

Empty result: region names are provider-prefixed (aws:eu-west-1). Zero rows means Whisper has not mapped that prefix to a tracked region. It never means the host is not in a cloud, and it is not evidence of on-premises hosting for an audit. If residency is load-bearing, confirm against the provider's published ranges and record that as the source. Zero rows is never a verdict.

From here, → What does the outside see in one row?.

Threat and sanctions screening

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.

What does the outside see in one row?

Before the detailed screen, take the one-row summary an external assessor starts from: where the vendor resolves, who registered it, and whether anything it hosts is on a threat feed. Every arm is optional, so the row always comes back, and thin data is itself a finding worth noting.

cypher · runnablegraph.whisper.securitySign in to run
// Hosting + registrar + threat exposure in one profile
MATCH (h:HOSTNAME {name: "github.com"})
OPTIONAL MATCH (h)-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)
RETURN h.name AS host,
       collect(DISTINCT ip.name)[0..5] AS ips,
       collect(DISTINCT r.name) AS registrars,
       collect(DISTINCT f.displayName)[0..5] AS threat_feeds
LIMIT 1

Returns: host, ips, registrars, threat_feeds

json
[{
  "host": "github.com",
  "ips": ["140.82.114.4", "140.82.121.3", "140.82.121.4", "20.205.243.166", "4.228.31.150"],
  "registrars": ["iana:292"],
  "threat_feeds": ["FireHOL Anonymous", "FireHOL Level 3"]
}]

Costs: milliseconds; one indexed anchor and three optional arms, each a single hop, with the collects bounded.

A feed listing on a large multi-tenant platform's address says little about the tenant; that is why the next recipe reads the reconciled verdict rather than the raw listing. Use f.displayName for the readable feed name; f.name is the slug.

From here, → What is the reconciled verdict, with its working?.

What is the reconciled verdict, with its working?

Feeds disagree, and "listed by two of them" says nothing without knowing which two and how they are weighted. explain() reconciles every feed into one inspectable verdict: score, normalized level, and the evidence chain you paste into the file.

cypher · runnablegraph.whisper.securitySign in to run
CALL explain("cloudflare.com")
YIELD indicator, type, found, score, level, explanation
RETURN indicator, type, found, score, level, explanation

Returns: indicator, type, found, score, level, explanation

json
[{
  "indicator": "cloudflare.com",
  "type": "domain",
  "found": true,
  "score": 0.0,
  "level": "NONE",
  "explanation": "cloudflare.com is listed in 0 threat feed(s). Score 0.0 (No known risk)."
}]

Costs: an anchored procedure call, no traversal; it behaves the same on an IP or CIDR, and on an ASN the composite is read from explanation and breakdown instead.

For an automated underwriting rule key off level (NONE / INFO / LOW / MEDIUM / HIGH / CRITICAL) rather than the raw score: the bands are calibrated for human-readable risk language. Name the columns you want in YIELD: explain() also returns factors[] with the scoring arithmetic and sources[] naming each feed with its weight and first/last seen, which is what a file needs to show its working. Signature.

From here, → Which flags can a rule engine branch on?.

Which flags can a rule engine branch on?

"Should we block this, and why" usually means buying a second product. Every listed node already carries a reconciled, blocking-aware verdict plus typed booleans a pipeline can branch on.

cypher · runnablegraph.whisper.securitySign in to run
// Reconciled verdict + blocking flag for a vendor's resolving IPs
MATCH (h:HOSTNAME {name: "github.com"})-[:RESOLVES_TO]->(ip:IPV4)
RETURN ip.name           AS ip,
       ip.verdictScore    AS score,
       ip.verdictLevel    AS level,
       ip.verdictBlocking AS should_block,
       ip.isC2, ip.isMalware, ip.isPhishing, ip.isBotnet
LIMIT 10

Returns: ip, score, level, should_block, isC2, isMalware, isPhishing, isBotnet

json
[{"ip": "140.82.121.3", "score": 0.8, "level": "LOW", "should_block": false, "ip.isC2": false, "ip.isMalware": false, "ip.isPhishing": false, "ip.isBotnet": false}]

Costs: milliseconds; one anchored hop, then a property read per address; the flags cost nothing extra.

verdictBlocking = true on a vendor's production address is a material finding, and the flags say what kind of exposure it is without a second lookup. Trust feeds live in the same model, so a benign popular domain reads as known-good rather than merely absent from block lists: Threat Feeds & Categories.

From here, → Which feeds and categories flag this address?.

Which feeds and categories flag this address?

Sanctions screening usually stops at company names, and a name match cannot see infrastructure. Every indicator carries a LISTED_IN edge to each feed that flagged it, with the feed's category attached: the difference between a score and a citation.

cypher · runnablegraph.whisper.securitySign in to run
// Which feeds and categories flag an IP, with the reconciled verdict
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)-[:BELONGS_TO]->(cat:CATEGORY)
RETURN ip.threatLevel AS verdict,
       ip.isThreat AS flagged,
       collect(DISTINCT f.displayName) AS feeds,
       collect(DISTINCT cat.displayName) AS categories
LIMIT 1

Returns: verdict, flagged, feeds, categories

json
[{
  "verdict": "LOW",
  "flagged": true,
  "feeds": ["GreenSnow Blacklist", "IPsum", "FireHOL Level 2", "Tor Exit Nodes", "StopForumSpam Listed IPs (7 day)"],
  "categories": ["General Blacklists", "TOR Network", "Spam"]
}]

Costs: milliseconds; an anchored property read plus a bounded two-hop expansion into the catalogue; use displayName for readable names, name is the slug.

threatScore, threatLevel and isThreat sit on the node, reconciled across every feed that lists it; the feed and category lists are the citation. The sanctions-relevant categories are OFAC SDN Sanctions and State Actor & Sanctions, out of 134 feeds across 32 categories.

From here, → Which domains on the watchlist resolve to flagged infrastructure?.

Which domains on the watchlist resolve to flagged infrastructure?

Screening a vendor list with flat tools is one threat lookup per name, then filtering. UNWIND the watchlist and let the node flags filter it in a single round-trip, so only the entries that matter come back.

cypher · runnablegraph.whisper.securitySign in to run
// Screen a domain watchlist for feed-flagged hosting
UNWIND ["paypal.com", "stripe.com", "webmail.inini.casa"] AS domain
MATCH (h:HOSTNAME {name: domain})-[:RESOLVES_TO]->(ip:IPV4)
WHERE ip.isThreat = true
RETURN domain,
       ip.name AS flagged_ip,
       ip.threatLevel AS verdict,
       ip.isTor AS tor_exit,
       ip.isAnonymizer AS anonymizer
LIMIT 50

Returns: domain, flagged_ip, verdict, tor_exit, anonymizer

json
[{
  "domain": "webmail.inini.casa",
  "flagged_ip": "78.128.76.165",
  "verdict": "CRITICAL",
  "tor_exit": false,
  "anonymizer": false
}]

Costs: milliseconds; every element is still an anchored name lookup with one hop; keep the list short enough to stay in one round-trip.

Only the flagged entry returns: paypal.com and stripe.com resolve fine and drop out because none of their addresses carry isThreat. A clean result set is a clean screen for that run; record the query time as the point-in-time stamp. One caveat that costs people findings: large CDNs host many tenants, so a listing somewhere inside their address space says little about your vendor, and anchoring on (h)-[:RESOLVES_TO]->(ip) is what keeps the question about this hostname.

From here, → Is this address a Tor exit or an anonymizer? on anything flagged.

Is this address a Tor exit or an anonymizer?

An address rotates, but its role as a Tor exit persists, and flat reputation lookups miss that identity layer.

cypher · runnablegraph.whisper.securitySign in to run
// Is an IP a Tor exit, and what's the reconciled anonymizer posture
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:OPERATES_EXIT_NODE]->(t:TOR_RELAY)
RETURN ip.name AS ip,
       ip.isTor AS is_tor,
       ip.isAnonymizer AS is_anonymizer,
       collect(t.name) AS tor_relay_fingerprints
LIMIT 1

Returns: ip, is_tor, is_anonymizer, tor_relay_fingerprints

json
[{"ip": "185.220.101.1", "is_tor": true, "is_anonymizer": true, "tor_relay_fingerprints": ["6c64100d8f7050e76f420ce404031eabc7101124", "8f744605199e75c26f74e818bde50d9a7325ec94"]}]

Costs: milliseconds; an anchored property read plus one optional hop to the relay.

isTor is a reconciled flag; the relay fingerprint behind it is the durable identity, survives an address change, and is what makes the finding citable. Anonymizing egress inside a vendor's production range is a question for the review, not by itself a finding.

From here, → Is a clean answer clean, or just empty?.

Is a clean answer clean, or just empty?

"No findings" reads as clean to everyone who opens your file, and absence of data is not evidence of safety. whisper.assess() returns the verdict with a coverage qualifier attached.

cypher · runnablegraph.whisper.securitySign in to run
// Verdict plus how much the graph actually knows about the host
CALL whisper.assess(["github.com"])
YIELD host, label, band, coverage, evidence
RETURN host, label, band, coverage, evidence
LIMIT 5

Returns: host, label, band, coverage, evidence

json
[{
  "host": "github.com",
  "label": "benign-allowlisted",
  "band": "NONE",
  "coverage": "known-clean",
  "evidence": ["coverage:known-clean", "band:NONE", "host-class:multi_tenant_user_content", "feed-source:listed", "feed-source-count:3", "advisory:url-scoped-listing", "popularity-rank:10"]
}]

Costs: a procedure call over a list of hosts, no traversal.

Identity is a separate question from verdict: whisper.identify() confirms whose infrastructure a host is before you score it. Both are on the Procedures page.

From here, → Are the routes carrying this vendor authorized?.

Routing integrity

Are the routes carrying this vendor authorized?

RPKI validation normally means cross-referencing announced origins against ROAs held in a separate system. The ROA layer is in the same graph.

cypher · runnablegraph.whisper.securitySign in to run
// Does the prefix carrying a vendor's IP have an RPKI ROA?
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)
OPTIONAL MATCH (roa:ROA)-[:ROA_AUTHORIZES_PREFIX]->(p)
RETURN DISTINCT p.name AS prefix,
       (roa IS NOT NULL) AS roa_protected,
       collect(DISTINCT roa.asn) AS authorized_origins
LIMIT 10

Returns: prefix, roa_protected, authorized_origins

json
[
  {"prefix": "104.16.0.0/12", "roa_protected": false, "authorized_origins": []},
  {"prefix": "104.16.128.0/20", "roa_protected": true, "authorized_origins": [13335]}
]

Costs: milliseconds; anchored fan-out, then two single hops and one optional ROA hop per prefix.

A vendor whose prefixes carry no ROA (roa_protected = false) is more exposed to route hijacking: routing hygiene flat DNS tooling cannot produce. For the live validation state of the announcement (rpkiStatus, rpkiInvalidReason), read the ANNOUNCED_PREFIX reached through ANNOUNCED_BY; BGP & RPKI has the full workup.

Empty result: no rows means the address did not reach an allocated prefix, not that routing is unprotected. roa_protected = false on a returned row is the finding; no row at all is a gap in the lookup. Zero rows is never a verdict.

From here, → Is the prefix in a MOAS conflict?.

Is the prefix in a MOAS conflict?

A prefix announced by two origin ASNs is the leading early signal of a BGP hijack, and it lives in routing data most risk tools never load.

cypher
// Is a vendor's prefix currently in a MOAS conflict?
MATCH (h:HOSTNAME {name: "trinitymgt.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
WHERE ap.isMoas = true
MATCH (ap)-[:CONFLICTS_WITH]->(other:ASN)
RETURN ap.name AS prefix, ap.moasIsLegitimate AS looks_legitimate,
       collect(DISTINCT other.name) AS conflicting_asns
LIMIT 10

Returns: prefix, looks_legitimate, conflicting_asns

json
[{"prefix": "38.22.219.0/24", "looks_legitimate": false, "conflicting_asns": ["AS100", "AS174"]}]

Costs: milliseconds; fan-out filtered on a boolean before the conflict hop, so the expansion only runs on prefixes already flagged; multi-origin state settles, so refresh the seed from the live conflict edge when this one goes quiet.

Empty result: an empty result is the answer you want, and the one you will usually get. The graph holds 11,307 CONFLICTS_WITH edges, so the overwhelming majority of vendors are not in conflict; the seed above is a small hosting customer whose block is. Read a blank result as this vendor's prefixes have a single origin today, not as we could not check. Zero rows is never a verdict. moasIsLegitimate is the triage column: anycast and deliberate multi-homing look like MOAS too. Pair it with whisper.history.bgp() on the prefix, which shows how the origin moved and catches a conflict that has already resolved.

From here, → How large is the external surface?.

Footprint and posture

How large is the external surface?

An organization's real surface is its subdomains, and many never appear in a DNS scan. Size the estate from the domain hierarchy, then add Certificate Transparency: names that only ever appeared inside a TLS certificate.

cypher · runnablegraph.whisper.securitySign in to run
// Size the subdomain estate from the anchored parent
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "github.com"})
RETURN count(sub) AS direct_children
LIMIT 1

Returns: direct_children

json
[{"direct_children": 36146}]
cypher
// Certificate-Transparency observations for a domain
MATCH (h:HOSTNAME {name: "partillebryggeri.se"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.fqdn AS observed_name, ct.certCount AS certs, ct.wildcard AS wildcard
ORDER BY ct.lastSeen DESC
LIMIT 20

Returns: observed_name, certs, wildcard

json
[{"observed_name": "partillebryggeri.se", "certs": 2, "wildcard": false}, {"observed_name": "*.partillebryggeri.se", "certs": 2, "wildcard": true}]

Costs: milliseconds; both anchored on the parent hostname and bounded. Certificate Transparency coverage is partial and recent, so a specific seed ages out of the window.

Empty result: zero rows on the second query means Whisper holds no recent certificate observation for that host. It never means the host has a clean certificate history. If certificate history is load-bearing, query a CT log directly (crt.sh or the Google CT API) and come back with the names you find. Zero rows is never a verdict.

Treat every count as a floor rather than a census; passive data reflects what was observed. A large footprint is not itself bad, but it sizes the surface, and a stray vpn., rdp. or old- host that still resolves is exactly what a questionnaire misses.

From here, → Which buildings does this vendor depend on?.

Physical dependency and concentration

Which buildings does this vendor depend on?

Assessing concentration risk for a third party, you want the physical facilities their network sits in, starting from nothing but a domain. This is the join no DNS or scan tool models: from a hostname all the way to a named building.

cypher · runnablegraph.whisper.securitySign in to run
// Domain -> ASN -> datacenters it's present in
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
WITH DISTINCT a LIMIT 3
MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
RETURN a.name AS asn, collect(DISTINCT f.name)[0..8] AS facilities
LIMIT 5

Returns: asn, facilities

json
[{"asn": "AS13335", "facilities": ["Equinix SV8 - Silicon Valley, Palo Alto", "Equinix SV1/SV5/SV10 - Silicon Valley, San Jose", "Equinix DA1 - Dallas", "Equinix DC1-DC15,DC21-DC22 - Ashburn"]}]

Costs: milliseconds; three single hops to the network, a WITH DISTINCT a LIMIT 3 to narrow it, then one hop to facilities; a large network is present in hundreds of buildings, so keep the bound.

Read the list for shape, not length: a vendor whose whole footprint funnels through a handful of buildings has a different loss profile from one spread across regions. Facility names are exact strings; copy them into the next recipe.

From here, → How many other networks share that building?.

How many other networks share that building?

You found the datacenters a service sits in. Now you want to know how crowded one of them is: how many networks converge on the same building, which is the concentration risk you are underwriting.

cypher · runnablegraph.whisper.securitySign in to run
// How many ASNs share one facility with the target network
MATCH (a:ASN {name: "AS13335"})-[:AS_PRESENT_AT]->(f:FACILITY)
WITH f LIMIT 1
MATCH (f)<-[:AS_PRESENT_AT]-(other:ASN)
RETURN f.name AS facility, count(DISTINCT other) AS asn_count
LIMIT 1

Returns: facility, asn_count

json
[{"facility": "Equinix SV8 - Silicon Valley, Palo Alto", "asn_count": 366}]

Costs: milliseconds; one anchored hop, a bound to one facility, then one inbound hop aggregated; anchor on FACILITY {name: "…"} directly when you already hold the building.

A facility where hundreds of networks converge is both resilient (lots of interconnection) and a concentration point (a single building outage touches many of them). Pair this with the datacenter map above to see whether a third party's whole footprint funnels through a handful of buildings.

From here, → Where does a submarine cable land, and what sits next to it?.

Where does a submarine cable land, and what sits next to it?

Subsea-cable dependency is now a regulated question in several jurisdictions. A cable's landing points and the datacenters near each one are the physical layer under every regional dependency in the file.

cypher · runnablegraph.whisper.securitySign in to run
// Cable -> landing points -> nearby facilities
MATCH (s:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(c:CABLE_LANDING)
OPTIONAL MATCH (c)-[:LANDING_NEAR]->(f:FACILITY)
RETURN c.name AS landing, collect(DISTINCT f.name)[0..4] AS facilities
LIMIT 10

Returns: landing, facilities

json
[
  {"landing": "Duynefontein, South Africa", "facilities": ["Africa Data Centres, Cape Town CPT1, South Africa", "Teraco CT1 Cape Town, South Africa", "OADC CPT1 - Cape Town", "OADC CPT3 - Cape Town"]},
  {"landing": "Dakar, Senegal", "facilities": ["ONIX Senegal", "PAIX Dakar"]}
]

Costs: milliseconds; one anchored hop plus an optional facility hop; keep LANDING_NEAR optional, some landings have no nearby facility on record and you still want the landing listed.

A handful of cables converging on the same landing region is the concentration risk worth surfacing. Walk LANDING_NEARFACILITYAS_PRESENT_AT to name the networks sitting closest to a landing.

From here, → What is the datacenter physically connected to?.

What is the datacenter physically connected to?

A datacenter with one terrestrial fiber path out of it is a different risk from one sitting on a mesh. FIBER_SEGMENT links facilities to the facilities they are fiber-connected to, so the next question in a resilience review is one hop from the building you already found.

cypher · runnablegraph.whisper.securitySign in to run
// The facilities a given network's datacenters are fiber-linked to
MATCH (a:ASN {name: "AS13335"})-[:AS_PRESENT_AT]->(f:FACILITY)
WITH f LIMIT 3
MATCH (f)-[:FIBER_SEGMENT]-(g:FACILITY)
RETURN f.name AS facility, collect(DISTINCT g.name)[0..5] AS linked_facilities
LIMIT 5

Returns: facility, linked_facilities

json
[
  {"facility": "Equinix SV8 - Silicon Valley, Palo Alto", "linked_facilities": ["NASA Moffet Field"]},
  {"facility": "Equinix DA1 - Dallas", "linked_facilities": ["Equinix DA2 - Dallas"]}
]

Costs: milliseconds; one anchored hop, a bound to a few facilities, then one undirected fiber hop; a link is recorded once in one direction, so traverse -[:FIBER_SEGMENT]- undirected to see both ends.

Fiber coverage is denser in some regions than others, so a facility showing one link is often a coverage statement rather than a topology statement: read a low link count as "we know of one path", not "there is only one". MATCH (f:FACILITY)-[:FIBER_SEGMENT]->(g:FACILITY) RETURN f.name, g.name LIMIT 10 reads the fiber layer on its own.

From here, → Do two CDN providers share the same buildings?.

Do two CDN providers share the same buildings?

Two content-delivery providers you treat as independent may land in the same buildings, in which case a building-level outage takes both of your "independent" delivery paths with it. CDN_POP_AT places each point of presence in a facility, so the overlap is a grouped read.

cypher · runnablegraph.whisper.securitySign in to run
// Facilities hosting points of presence for more than one CDN operator
MATCH (c:CDN_POP)-[:CDN_POP_AT]->(f:FACILITY)
WITH f, collect(DISTINCT c.operator) AS operators
WHERE size(operators) > 1
RETURN f.name AS facility, operators
ORDER BY size(operators) DESC
LIMIT 10

Returns: facility, operators

json
[
  {"facility": "Equinix SY1/SY2 - Sydney", "operators": ["akamai", "cloudflare", "cloudfront", "fastly", "google-cdn", "microsoft-cdn"]},
  {"facility": "NEXTDC M1", "operators": ["akamai", "cloudflare", "cloudfront", "fastly", "google-cdn", "microsoft-cdn"]}
]

Costs: milliseconds; a grouped read over the whole PoP layer, which is small enough to scan; no seed needed.

Group and filter on c.operator, c.city or c.countryCode; CDN_POP.name is an internal identifier of the form operator:source:id, not a hostname. For the footprint of each operator on its own, RETURN c.operator, count(DISTINCT f) over the same pattern ranks providers by the number of facilities they are present in.

From here, → Is a dependency flagged as critical infrastructure?.

Is a dependency flagged as critical infrastructure?

Building a dependency register, you want to separate ordinary commercial transit from the networks that carry national or research infrastructure: the ones whose disruption has consequences past your own service.

cypher
// Networks carrying a critical-infrastructure signal
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "critical-infrastructure"})
WITH a LIMIT 10
OPTIONAL MATCH (a)-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name AS asn, n.name AS operator
LIMIT 10

Returns: asn, operator

json
[
  {"asn": "AS10094", "operator": "UNN-BN - Unified National Networks"},
  {"asn": "AS10131", "operator": "CKTELECOM-CK-AP - Telecom Cook Islands"}
]

Costs: milliseconds; anchored on the signal node with one hop and an optional name hop; bound the input with WITH a LIMIT n before any country join.

The signal marks national research and internet-registry networks, university backbones and national exchange operators: it is a significance label, not a threat label. A dependency landing here is usually a good sign about the operator and a bad sign about your concentration. To check one vendor's network, anchor it: MATCH (a:ASN {name: "AS13335"}) OPTIONAL MATCH (a)-[:HAS_SIGNAL]->(s:THREAT_SIGNAL_TYPE) RETURN collect(s.name) returns every operator-level signal it carries (bulletproof-hosting, critical-infrastructure, ddos-mitigation, satellite-network, asn-death-spiral).

From here, → What is the vendor's known vulnerability exposure?.

Vulnerability exposure

What is the vendor's known vulnerability exposure?

A vendor review needs a CVE dimension and you have no right to scan them. whisper.vulnPosture returns the aggregate exposure the graph already knows about for a host: counts by severity, whether anything is on a known-exploited list, and an honest coverage flag.

cypher · runnablegraph.whisper.securitySign in to run
// Aggregate CVE exposure for a host
CALL whisper.vulnPosture("github.com")
YIELD openCveCount, critical, high, medium, low, kevCount, ransomwareCount, maxEpss, maxCvss, coverage
RETURN openCveCount, critical, high, medium, low, kevCount, ransomwareCount, maxEpss, maxCvss, coverage
LIMIT 3

Returns: openCveCount, critical, high, medium, low, kevCount, ransomwareCount, maxEpss, maxCvss, coverage

json
[{"openCveCount": 1, "critical": 0, "high": 0, "medium": 0, "low": 0, "kevCount": 0, "ransomwareCount": 0, "maxEpss": 0.0, "maxCvss": 0.0, "coverage": "partial"}]

Costs: milliseconds; a procedure call over precomputed software identification, no traversal; the argument is a single quoted hostname, not a list and not a URL.

Read coverage before anything else: anything short of a complete inventory means the graph has some software identification for the host but not all of it, so the counts are a floor, not an assessment. kevCount and ransomwareCount are the columns that change a conversation; a known-exploited vulnerability is a different argument from a high CVSS score. Treat the whole result as external, passive evidence to start a vendor conversation, never as a substitute for an authenticated scan.

From here, → Which CVEs affect a package on their bill of materials?.

Which CVEs affect a package on their bill of materials?

A software bill of materials names a package and version, and you want its known vulnerabilities ranked by how likely they are to be exploited rather than by raw severity.

cypher · runnablegraph.whisper.securitySign in to run
// Known CVEs affecting a specific package version, exploitation-ranked
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

Returns: cve, band, kev, ransomware, epss, cvss, coverage

json
[
  {"cve": "CVE-2014-0160", "band": "CRITICAL", "kev": true, "ransomware": false, "epss": 1.0, "cvss": 7.5, "coverage": "known-cve"},
  {"cve": "CVE-2022-2068", "band": "CRITICAL", "kev": false, "ransomware": false, "epss": 0.9576, "cvss": 9.8, "coverage": "known-cve"}
]

Costs: milliseconds; a procedure call, no traversal; the argument must be a full CPE 2.3 string, wildcards and all.

Hand it a Package URL (pkg:generic/openssl@3.0.0) and the call succeeds but returns a single row with coverage: "unsupported-spec" and every other column null, a quiet no-answer that is easy to misread as "no CVEs". Check coverage first, every time. Once you have rows, epss is the column to sort on: it estimates real-world exploitation probability, which is why the first row above outranks a higher-CVSS entry below it. kev: true means it is on a known-exploited list, and that beats every score in the table.

From here, → Who may send as each domain in the portfolio, and where do the reports go?.

Who may send as each domain in the portfolio, and where do the reports go?

Email posture across a portfolio is a TXT lookup and a parse per domain. Here the SPF includes, the DMARC report destinations and the DKIM signers are edges, so UNWIND reports the whole list at once and the unsigned or unreported zones stand out.

cypher · runnablegraph.whisper.securitySign in to run
// SPF includes, DMARC destinations and DKIM signers across a domain portfolio
UNWIND ["google.com", "cloudflare.com", "paypal.com", "stripe.com"] AS domain
MATCH (h:HOSTNAME {name: domain})
OPTIONAL MATCH (h)-[:SPF_INCLUDE]->(inc:HOSTNAME)
OPTIONAL MATCH (h)-[:DMARC_REPORTS_TO]->(d:DMARC_RECIPIENT)
OPTIONAL MATCH (h)-[:DKIM_SIGNED_BY]->(v:VENDOR)
RETURN domain,
       collect(DISTINCT inc.name) AS spf_includes,
       collect(DISTINCT d.name)   AS dmarc_report_destinations,
       collect(DISTINCT v.name)   AS dkim_signers
ORDER BY domain
LIMIT 100

Returns: domain, spf_includes, dmarc_report_destinations, dkim_signers

json
[
  {"domain": "google.com", "spf_includes": ["_spf.google.com"], "dmarc_report_destinations": ["mailauth-reports@google.com"], "dkim_signers": []},
  {"domain": "stripe.com", "spf_includes": ["_spf.qualtrics.com", "greenhouse-outbound-mail.stripe.com", "spf1.stripe.com"], "dmarc_report_destinations": ["dmarc-reports@stripe.com"], "dkim_signers": ["google"]}
]

Costs: milliseconds; one anchored lookup per element plus three optional hops each; drop the UNWIND line and anchor a single name for one domain.

Empty result: an empty dmarc_report_destinations or dkim_signers list means nothing is recorded for that zone. Verify with a direct TXT lookup before flagging it as a gap in the vendor's posture. Zero rows is never a verdict.

The six SPF edge types (SPF_INCLUDE, SPF_IP, SPF_A, SPF_MX, SPF_EXISTS, SPF_REDIRECT) walk the full authorization tree when an auditor asks who may send as this domain, and a DKIM signer that appears nowhere in SPF is a mail platform nobody told the DNS owner about. Full workup: Posture Audits.

From here, → What does the registration record actually say? to close the loop and assemble the file.

Run it from a terminal

The endpoint answers a plain curl, which is enough to sanity-check a resolution before wiring anything up. Send an explicit User-Agent; requests without one may be refused.

bash
curl -s -A "whisper-client/1.0" https://graph.whisper.security/api/query \
  -H "Content-Type: application/json" \
  -d '{"query":"MATCH (h:HOSTNAME {name:\"stripe.com\"})-[:RESOLVES_TO]->(ip:IPV4) RETURN h.name, ip.name LIMIT 5"}'

Where to next

  • Wire it into an agent. Point an MCP client at https://mcp.whisper.security and a due-diligence assistant runs these traversals mid-conversation, citing graph edges instead of guessing: AI & Agents, MCP setup.
  • Score third-party risk in your SIEM. The whisper_explain macro puts the same reconciled verdict beside your own events: Splunk.
  • Reusable pivots. Cross-Layer Patterns catalogs the building blocks; Procedures covers explain(), whisper.history(), whisper.origins(), whisper.vulnPosture() and the rest.
  • Know your feeds. Threat Feeds & Categories names every feed and category behind a screening decision.