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.
On this page (36)
- Registration and ownership
- What does the registration record actually say?
- Who registered it, and what else do they own?
- When did the registrar or nameservers last change?
- Hosting attribution and network reputation
- Who hosts this vendor, and is that network clean?
- Who actually runs this, behind the AS and the CDN?
- Jurisdiction and data residency
- Which countries can this traffic land in?
- Is it in the cloud region the contract names?
- Threat and sanctions screening
- What does the outside see in one row?
- What is the reconciled verdict, with its working?
- Which flags can a rule engine branch on?
- Which feeds and categories flag this address?
- Which domains on the watchlist resolve to flagged infrastructure?
- Is this address a Tor exit or an anonymizer?
- Is a clean answer clean, or just empty?
- Routing integrity
- Are the routes carrying this vendor authorized?
- Is the prefix in a MOAS conflict?
- Footprint and posture
- How large is the external surface?
- Physical dependency and concentration
- Which buildings does this vendor depend on?
- How many other networks share that building?
- Where does a submarine cable land, and what sits next to it?
- What is the datacenter physically connected to?
- Do two CDN providers share the same buildings?
- Is a dependency flagged as critical infrastructure?
- Vulnerability exposure
- What is the vendor's known vulnerability exposure?
- Which CVEs affect a package on their bill of materials?
- Who may send as each domain in the portfolio, and where do the reports go?
- Run it from a terminal
- Where to next
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-Keyheader.
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.
// 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
[{
"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 mandatoryMATCHon a missing WHOIS field drops the whole row, which is how an evidence pack loses a vendor. Registrar ids use theiana:NNNNform, 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
nameserverstwice: 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_includesabove 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.
// 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
[{
"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_ENTITYrecords carry no edge of any type, so none is reachable from a domain or citable as provenance. Registration runs throughREGISTERED_BY→ORGANIZATION,HAS_EMAIL,HAS_PHONEandHAS_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.
// 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
[
{"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.
// 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
[{"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 = trueon 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 plusmaxThreatScore,avgThreatScoreand the routing posture (hijackPostureScore,routeLeakCount). AddOPTIONAL MATCH (a)-[:HAS_COUNTRY]->(co:COUNTRY)for the operator's registered home jurisdiction, orexplain("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.
// 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
[
{"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_byreturns["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.
CALL whisper.origins("cloudflare.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
ORDER BY confidence DESC
LIMIT 10
[{"ip": "100.21.79.143", "confidence": 0.4499, "methods": ["sibling"], "asnName": "AMAZON-02 - Amazon.com, Inc."}]
methodsnames 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.
// 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
[{"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.
// 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
[{"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
coveragebeforeband. 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.explaindoes not returncoverageat 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.
// 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
[{
"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.displayNamefor the readable feed name;f.nameis 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.
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
[{
"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 rawscore: the bands are calibrated for human-readable risk language. Name the columns you want inYIELD:explain()also returnsfactors[]with the scoring arithmetic andsources[]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.
// 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
[{"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 = trueon 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.
// 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
[{
"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,threatLevelandisThreatsit on the node, reconciled across every feed that lists it; the feed and category lists are the citation. The sanctions-relevant categories areOFAC SDN SanctionsandState 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.
// 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
[{
"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.comandstripe.comresolve fine and drop out because none of their addresses carryisThreat. 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.
// 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
[{"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.
isToris 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.
// 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
[{
"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.
// 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
[
{"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 theANNOUNCED_PREFIXreached throughANNOUNCED_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 = falseon 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.
// 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
[{"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_WITHedges, 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.moasIsLegitimateis the triage column: anycast and deliberate multi-homing look like MOAS too. Pair it withwhisper.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.
// 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
[{"direct_children": 36146}]
// 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
[{"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.orold-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.
// 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
[{"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.
// 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
[{"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.
// 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
[
{"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_NEAR→FACILITY←AS_PRESENT_ATto 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.
// 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
[
{"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 10reads 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.
// 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
[
{"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.cityorc.countryCode;CDN_POP.nameis an internal identifier of the formoperator: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.
// 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
[
{"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.
// 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
[{"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
coveragebefore 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.kevCountandransomwareCountare 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.
// 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
[
{"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 withcoverage: "unsupported-spec"and every other column null, a quiet no-answer that is easy to misread as "no CVEs". Checkcoveragefirst, every time. Once you have rows,epssis 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: truemeans 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.
// 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
[
{"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_destinationsordkim_signerslist 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.
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.securityand 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_explainmacro 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.