whisper-id 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -17
- package/index.d.ts +461 -4
- package/index.js +401 -24
- package/package.json +33 -7
package/README.md
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
A real, routable **IPv6 identity** and **safe egress** for any Node agent.
|
|
4
4
|
|
|
5
|
-
**Two-tier, by design** (Postel's Law): **with no key** the keyless half works everywhere
|
|
6
|
-
`verify`, `verifyDetails`, `rdap`
|
|
7
|
-
your key** (`opts.apiKey` or `WHISPER_API_KEY`) the whole control plane unlocks
|
|
8
|
-
logs, revoke, identity, agent
|
|
5
|
+
**Two-tier, by design** (Postel's Law): **with no key** the keyless half works everywhere.
|
|
6
|
+
`verify`, `verifyDetails`, and `rdap` let anyone prove an address is a Whisper agent. **With
|
|
7
|
+
your key** (`opts.apiKey` or `WHISPER_API_KEY`) the whole control plane unlocks (list, policy,
|
|
8
|
+
logs, revoke, identity, agent), plus the keyed **`graph`** namespace (Cypher intelligence over
|
|
9
|
+
the Whisper Security graph) and **egress** that sources your traffic from your `/128`.
|
|
9
10
|
|
|
10
11
|
```sh
|
|
11
12
|
npm i whisper-id
|
|
@@ -13,7 +14,7 @@ npm i whisper-id
|
|
|
13
14
|
|
|
14
15
|
```js
|
|
15
16
|
import { verify } from "whisper-id";
|
|
16
|
-
await verify("2a04:2a01
|
|
17
|
+
await verify("2a04:2a01:..."); // keyless: true if it's a real Whisper agent
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
```js
|
|
@@ -28,14 +29,14 @@ await withEgress(async (e) => {
|
|
|
28
29
|
|
|
29
30
|
## API
|
|
30
31
|
|
|
31
|
-
### Keyless
|
|
32
|
+
### Keyless: no key, no CLI (pure HTTPS)
|
|
32
33
|
|
|
33
34
|
Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser).
|
|
34
35
|
|
|
35
36
|
| Call | Does |
|
|
36
37
|
|------|------|
|
|
37
38
|
| `verify(address)` | Is `address` a real Whisper agent? (server-side DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
|
|
38
|
-
| `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`,
|
|
39
|
+
| `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, and more) or `null` |
|
|
39
40
|
| `rdap(address)` | The public RDAP record for a `/128`, or `null` |
|
|
40
41
|
| `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
|
|
41
42
|
|
|
@@ -44,21 +45,21 @@ import { verify, rdap } from "whisper-id";
|
|
|
44
45
|
if (await verify(addr)) console.log((await rdap(addr)).name);
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
### Control plane
|
|
48
|
+
### Control plane: with a key (pure HTTP, no CLI)
|
|
48
49
|
|
|
49
|
-
Pure `fetch` to the control endpoint
|
|
50
|
+
Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
|
|
50
51
|
`opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
|
|
51
52
|
never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
|
|
52
53
|
carrying the server's `detail` (plus `.status` / `.retryAfter`).
|
|
53
54
|
|
|
54
55
|
| Call | Does |
|
|
55
56
|
|------|------|
|
|
56
|
-
| `list({ kind })` | List your fleet
|
|
57
|
+
| `list({ kind })` | List your fleet. `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
|
|
57
58
|
| `identity({ label, contact_email })` | Allocate your own `/128`. Release with `{ release: true, address }`. → `{ agent, address, fqdn, ptr, state }` |
|
|
58
59
|
| `agent(idOrAddress)` | One agent's detail + live counters. → the record, or `null` |
|
|
59
60
|
| `policy({ default, allow, block })` | Read (no fields) or set your per-tenant DNS policy. → `{ key, value }` rows |
|
|
60
61
|
| `logs({ agent, kind, from, to, limit })` | Recent DNS/conn/alloc activity from warm storage. → event records |
|
|
61
|
-
| `revoke(idOrAddress)` | Fully revoke an agent
|
|
62
|
+
| `revoke(idOrAddress)` | Fully revoke an agent, **irreversible** (needs `admin:dns`). → `{ status }` |
|
|
62
63
|
|
|
63
64
|
```js
|
|
64
65
|
import { list, policy, logs } from "whisper-id"; // WHISPER_API_KEY in the env
|
|
@@ -68,11 +69,67 @@ for (const a of await list()) console.log(a.address, a.label);
|
|
|
68
69
|
const recent = await logs({ kind: "dns", limit: 100 });
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
### Graph (keyed Cypher, pure HTTP)
|
|
73
|
+
|
|
74
|
+
The intelligence half of the keyed surface. `import { graph } from "whisper-id"` and you get one
|
|
75
|
+
typed method per catalog entry over the Whisper Security graph, plus a raw `graph.query` escape
|
|
76
|
+
hatch. Same auth as the control plane: the key comes from `opts.apiKey` or `WHISPER_API_KEY` and
|
|
77
|
+
is sent only in `X-API-Key`. If it is Cypher, it needs a key: this whole namespace is keyed (the
|
|
78
|
+
keyless `verify` / `verifyDetails` / `rdap` above touch no Cypher and stay key-free). Every method
|
|
79
|
+
resolves to `{ columns, rows, statistics }` where `rows` are objects keyed by column name.
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { graph } from "whisper-id"; // WHISPER_API_KEY in the env, or pass { apiKey }
|
|
83
|
+
|
|
84
|
+
const r = await graph.identify("api.openai.com", { apiKey: "whisper-..." });
|
|
85
|
+
console.log(r.rows[0].vendor_id, r.rows[0].canonical_name); // → openai OpenAI
|
|
86
|
+
|
|
87
|
+
await graph.assess("185.220.101.33"); // threat posture: malicious / benign / unknown
|
|
88
|
+
await graph.variants("paypal.com"); // registered look-alike domains
|
|
89
|
+
await graph.origins("cloudflare.com"); // real origin IPs behind a CDN
|
|
90
|
+
await graph.query("MATCH (n) RETURN n LIMIT $lim", { lim: 5 }); // raw Cypher escape hatch
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Call | Does |
|
|
94
|
+
|------|------|
|
|
95
|
+
| `graph.identify(value)` | Name the vendor and operator role behind a host or IP |
|
|
96
|
+
| `graph.assess(value)` | Labelled threat posture for a host or IP (malicious / benign / unknown) |
|
|
97
|
+
| `graph.variants(value)` | Look-alike domain variants of a brand, and which are registered |
|
|
98
|
+
| `graph.walk(value)` | Walk to the nearest known vendors behind a host, with channel + confidence |
|
|
99
|
+
| `graph.explain(value)` | Score an indicator against the threat feeds and explain why |
|
|
100
|
+
| `graph.pslTldplusone(value)` | Reduce any hostname to its registrable apex (eTLD+1) |
|
|
101
|
+
| `graph.pslAffiliation(value)` | Is a domain a PSL private-section suffix, and who submitted it |
|
|
102
|
+
| `graph.origins(value)` | Real origin IPs behind a CDN-fronted domain, ranked by confidence |
|
|
103
|
+
| `graph.history(value)` | Full historical WHOIS timeline for a domain |
|
|
104
|
+
| `graph.historyWhois(value)` | WHOIS-only historical timeline for a domain |
|
|
105
|
+
| `graph.asset(value)` | Member ASNs of an AS-SET macro |
|
|
106
|
+
| `graph.lookupTorRelay(value)` | Is an IP a known Tor exit relay |
|
|
107
|
+
| `graph.dbSchema()` | Every node + relationship type in the graph, with counts and examples |
|
|
108
|
+
| `graph.submit({ kind, ...fields })` | Contribute an indicator observation or feedback (keyed write) |
|
|
109
|
+
| `graph.query(cypher, params)` | Raw Cypher escape hatch. → the full `{ columns, rows, statistics }` |
|
|
110
|
+
|
|
111
|
+
Fifteen multi-step **workflow** methods (`attackPath`, `attackSurface`, `blastRadius`, `typosquat`,
|
|
112
|
+
`indicator`, `routeHealth`, and 9 more) execute on the Whisper workflow runner and stream
|
|
113
|
+
Server-Sent Events; one call collects the whole run into `{ slug, steps, complete, events }`:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
const run = await graph.typosquat("paypal.com"); // bare value → the flow's primary input
|
|
117
|
+
console.log(run.steps.length, "steps"); // each step: its bound Cypher, columns, rows
|
|
118
|
+
console.log(run.complete.totalLatencyMs, "ms"); // the terminal aggregate context
|
|
119
|
+
|
|
120
|
+
await graph.attackSurface({ domain: "github.com", level: "deep" }); // named inputs + knobs
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Every `graph` method is generated from the Whisper query catalog (never hand-copied, so it
|
|
124
|
+
cannot drift) and links its canonical docs: `graph.identify.docs` →
|
|
125
|
+
`https://www.whisper.security/docs/whisper-graph/procedures/identify`, and the same URL is on
|
|
126
|
+
each method's `@see` tag in your editor.
|
|
127
|
+
|
|
71
128
|
### Egress + CLI-backed
|
|
72
129
|
|
|
73
130
|
| Call | Does |
|
|
74
131
|
|------|------|
|
|
75
|
-
| `register(name, { newKey })` | Create a named agent via the CLI
|
|
132
|
+
| `register(name, { newKey })` | Create a named agent via the CLI: a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
|
|
76
133
|
| `egress({ agent, tier, setEnv })` | Bring up egress bound to your `/128`. → `Egress { port, proxyUrl, socksUrl, proxies, close() }`. `tier: "wireguard"` for a routed `/128`. Sets proxy env unless `setEnv:false`; call `close()` to restore. |
|
|
77
134
|
| `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
|
|
78
135
|
| `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
|
|
@@ -99,7 +156,7 @@ transparently inside `withEgress`.
|
|
|
99
156
|
## Requirements
|
|
100
157
|
|
|
101
158
|
Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
|
|
102
|
-
global `fetch`)
|
|
159
|
+
global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
|
|
103
160
|
pass `{ apiKey }`).
|
|
104
161
|
|
|
105
162
|
Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
|
|
@@ -109,12 +166,12 @@ Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `i
|
|
|
109
166
|
curl get.whisper.online | sh
|
|
110
167
|
```
|
|
111
168
|
|
|
112
|
-
(`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$
|
|
113
|
-
endpoints.)
|
|
169
|
+
(`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
|
|
170
|
+
`$WHISPER_RDAP_URL` override the endpoints.)
|
|
114
171
|
|
|
115
172
|
## Links
|
|
116
173
|
|
|
117
|
-
- Site
|
|
118
|
-
- CLI
|
|
174
|
+
- Site: https://whisper.online
|
|
175
|
+
- CLI: https://github.com/whisper-sec/whisper-cli
|
|
119
176
|
|
|
120
177
|
MIT licensed.
|
package/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export declare class WhisperError extends Error {}
|
|
4
4
|
|
|
5
5
|
export interface Agent {
|
|
6
|
-
/** The routable IPv6 /128
|
|
6
|
+
/** The routable IPv6 /128 - both the identity and the auth. */
|
|
7
7
|
address: string;
|
|
8
8
|
id: string | null;
|
|
9
9
|
name: string;
|
|
@@ -51,7 +51,7 @@ export function egressIp(opts?: { timeout?: number }): Promise<string>;
|
|
|
51
51
|
export function ip(opts?: { timeout?: number }): Promise<string>;
|
|
52
52
|
export function firstAddr(obj: unknown): string | null;
|
|
53
53
|
|
|
54
|
-
// ── Control plane (pure HTTP, keyed
|
|
54
|
+
// ── Control plane (pure HTTP, keyed - no CLI) ───────────────────────────────────────
|
|
55
55
|
// The governance half of the two-tier surface: pure `fetch` to the control endpoint, so
|
|
56
56
|
// it runs anywhere (serverless, edge, a plain server). The key is read from `opts.apiKey`
|
|
57
57
|
// or `WHISPER_API_KEY` and sent only in the `X-API-Key` header. On failure these throw a
|
|
@@ -59,7 +59,7 @@ export function firstAddr(obj: unknown): string | null;
|
|
|
59
59
|
|
|
60
60
|
/** Common options for every keyed control call. */
|
|
61
61
|
export interface ControlOptions {
|
|
62
|
-
/** The Whisper owner key (`whisper_live_
|
|
62
|
+
/** The Whisper owner key (`whisper_live_...`). Falls back to `process.env.WHISPER_API_KEY`. */
|
|
63
63
|
apiKey?: string;
|
|
64
64
|
/** Per-call timeout in ms (default 60000). */
|
|
65
65
|
timeout?: number;
|
|
@@ -142,7 +142,7 @@ export function list(opts?: ListOptions): Promise<AgentItem[]>;
|
|
|
142
142
|
export function policy(opts?: PolicyOptions): Promise<PolicyEntry[]>;
|
|
143
143
|
/** Query recent DNS/conn/alloc activity from warm storage. → event records. */
|
|
144
144
|
export function logs(opts?: LogsOptions): Promise<LogEvent[]>;
|
|
145
|
-
/** Fully revoke an agent
|
|
145
|
+
/** Fully revoke an agent - IRREVERSIBLE (admin:dns). → `{ status, ... }`. */
|
|
146
146
|
export function revoke(agent: string, opts?: ControlOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
147
147
|
export function revoke(opts: RevokeOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
148
148
|
/** Allocate your own /128 (or release with `{release:true,address}`). → `{agent,address,fqdn,ptr,state}`. */
|
|
@@ -150,3 +150,460 @@ export function identity(opts?: IdentityOptions): Promise<AgentItem | null>;
|
|
|
150
150
|
/** One agent's detail + live counters. Accepts an id, a /128, or `{agent}`/`{address}`. */
|
|
151
151
|
export function agent(sel: string, opts?: ControlOptions): Promise<AgentItem | null>;
|
|
152
152
|
export function agent(opts?: AgentSelectOptions): Promise<AgentItem | null>;
|
|
153
|
+
|
|
154
|
+
// ── Graph (keyed: Cypher + flows over the Whisper Security graph) ────────────────────
|
|
155
|
+
// The intelligence half of the keyed surface. Postel two-tier: the keyless identity calls
|
|
156
|
+
// above (verify/verifyDetails/rdap) run with no key and no Cypher; this whole `graph`
|
|
157
|
+
// namespace is KEYED (Kaveh's rule: if it is Cypher, it needs a key). The 14 `direct`
|
|
158
|
+
// methods POST their catalog Cypher to /api/query; the 15 `flow` methods execute on the
|
|
159
|
+
// workflow runner (POST gallery/run, Server-Sent Events) and resolve to the collected
|
|
160
|
+
// stream. The key rides only in `X-API-Key`. The method surface below is GENERATED from
|
|
161
|
+
// the Whisper query catalog by scripts/gen-graph.mjs; every method's docs live at the
|
|
162
|
+
// `@see` URL and are also on the function itself as `.docs`.
|
|
163
|
+
|
|
164
|
+
/** Per-call options for the keyed graph namespace (same shape as the control plane). */
|
|
165
|
+
export interface GraphOptions extends ControlOptions {}
|
|
166
|
+
|
|
167
|
+
/** Result of a keyed graph query. `rows` are objects keyed by column name. */
|
|
168
|
+
export interface GraphResult {
|
|
169
|
+
columns: string[];
|
|
170
|
+
rows: Record<string, unknown>[];
|
|
171
|
+
statistics?: { rowCount?: number; executionTimeMs?: number; [k: string]: unknown } | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A `graph.submit` payload: an indicator observation or a piece of feedback. */
|
|
175
|
+
export interface GraphSubmission {
|
|
176
|
+
kind: "indicator" | "feedback" | string;
|
|
177
|
+
identifier_kind?: string;
|
|
178
|
+
identifierKind?: string;
|
|
179
|
+
value?: string;
|
|
180
|
+
observation_id?: string;
|
|
181
|
+
observationId?: string;
|
|
182
|
+
confidence?: number;
|
|
183
|
+
first_seen?: string;
|
|
184
|
+
firstSeen?: string;
|
|
185
|
+
provenance?: string;
|
|
186
|
+
query?: string;
|
|
187
|
+
results?: unknown;
|
|
188
|
+
comment?: string;
|
|
189
|
+
severity?: string;
|
|
190
|
+
v?: number;
|
|
191
|
+
[k: string]: unknown;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Input for a flow method: a bare value for the flow's primary input, a plain object of
|
|
196
|
+
* named inputs (a known knob name routes to params), or an explicit `{ inputs, params }`.
|
|
197
|
+
*/
|
|
198
|
+
export type GraphFlowInput =
|
|
199
|
+
| string
|
|
200
|
+
| number
|
|
201
|
+
| Record<string, unknown>
|
|
202
|
+
| { inputs?: Record<string, unknown>; params?: Record<string, unknown> };
|
|
203
|
+
|
|
204
|
+
/** One Server-Sent Event from a flow run; `data` is JSON-decoded when possible. */
|
|
205
|
+
export interface GraphFlowEvent {
|
|
206
|
+
event: string;
|
|
207
|
+
data: unknown;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Result of a flow run: every `step` event's payload (each carries the step's bound
|
|
212
|
+
* Cypher, columns, and rows), the terminal `complete` payload (the aggregate context;
|
|
213
|
+
* `null` if the stream ended early), and the full raw event list.
|
|
214
|
+
*/
|
|
215
|
+
export interface GraphFlowResult {
|
|
216
|
+
slug: string;
|
|
217
|
+
steps: Record<string, unknown>[];
|
|
218
|
+
complete: Record<string, unknown> | null;
|
|
219
|
+
events: GraphFlowEvent[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The keyed graph namespace: one typed method per catalog entry (14 direct Cypher reads +
|
|
224
|
+
* 15 workflow-runner flows), plus a raw `query` escape hatch. Every method needs a key
|
|
225
|
+
* (`{ apiKey }` or `WHISPER_API_KEY`) and carries its canonical docs URL as `.docs`.
|
|
226
|
+
*/
|
|
227
|
+
export interface GraphNamespace {
|
|
228
|
+
/**
|
|
229
|
+
* Raw Cypher escape hatch: run arbitrary Cypher with `params` bound server-side as
|
|
230
|
+
* $-parameters. Returns the full `{columns,rows,statistics}`.
|
|
231
|
+
* @see https://www.whisper.security/docs/cypher-api
|
|
232
|
+
*/
|
|
233
|
+
query(cypher: string, params?: Record<string, unknown>, opts?: GraphOptions): Promise<GraphResult>;
|
|
234
|
+
|
|
235
|
+
// BEGIN GENERATED: graph catalog (scripts/gen-graph.mjs) - do not edit by hand
|
|
236
|
+
/**
|
|
237
|
+
* Assess how resilient a country's core DNS is if it were cut off from the world.
|
|
238
|
+
*
|
|
239
|
+
* Could a country still resolve names if it were isolated? Counts how many of the 13 root
|
|
240
|
+
* letters have in-country DNS_ROOT_INSTANCE anycast nodes, the Global-vs-Local split, the BGP
|
|
241
|
+
* origin ASNs hosting them, and a resilience grade.
|
|
242
|
+
*
|
|
243
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "anycast-dns-root-sovereignty")
|
|
244
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
245
|
+
* Inputs: country. Params: instanceType.
|
|
246
|
+
* @see https://www.whisper.security/docs/recipes/compliance
|
|
247
|
+
*/
|
|
248
|
+
anycastDnsRootSovereignty(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Find the choke points an attacker would target - and how any two things connect.
|
|
252
|
+
*
|
|
253
|
+
* From a starting foothold, finds shared dependencies whose compromise reaches furthest; for
|
|
254
|
+
* any two indicators, traces how they are actually connected.
|
|
255
|
+
*
|
|
256
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "attack-path")
|
|
257
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
258
|
+
* Inputs: value, other. Params: level.
|
|
259
|
+
* @see https://www.whisper.security/docs/recipes/attack-path
|
|
260
|
+
*/
|
|
261
|
+
attackPath(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Map everything about a domain that's exposed to the outside world, scored for risk.
|
|
265
|
+
*
|
|
266
|
+
* Maps the full external footprint - subdomains, name/mail servers, registrant, third-party
|
|
267
|
+
* services, connected web - and scores the exposure.
|
|
268
|
+
*
|
|
269
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "attack-surface")
|
|
270
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
271
|
+
* Inputs: domain. Params: level.
|
|
272
|
+
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
273
|
+
*/
|
|
274
|
+
attackSurface(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Grade a network's routing security and trace conflicts to the domains they'd expose.
|
|
278
|
+
*
|
|
279
|
+
* Grades a network on the conflicts/gaps that make route hijacking possible, then traces any
|
|
280
|
+
* conflict to the specific domains and organisations exposed on the affected blocks.
|
|
281
|
+
*
|
|
282
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "bgp-hijack-exposure")
|
|
283
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
284
|
+
* Inputs: value.
|
|
285
|
+
* @see https://www.whisper.security/docs/recipes/bgp-routing
|
|
286
|
+
*/
|
|
287
|
+
bgpHijackExposure(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Pick one asset and see what would break if it failed - and what it depends on in turn.
|
|
291
|
+
*
|
|
292
|
+
* Maps dependencies in both directions: everything that breaks if the asset fails (SPOFs) and
|
|
293
|
+
* everything it relies on (its own DNS/mail/hosting/network supply chain).
|
|
294
|
+
*
|
|
295
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "blast-radius")
|
|
296
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
297
|
+
* Inputs: indicator. Params: depth.
|
|
298
|
+
* @see https://www.whisper.security/docs/recipes/soc
|
|
299
|
+
*/
|
|
300
|
+
blastRadius(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Assemble a ready-to-submit dossier for taking down a scam or phishing domain.
|
|
304
|
+
*
|
|
305
|
+
* One-pass takedown package: reputation verdict, owner (WHOIS), abuse-list listings, and
|
|
306
|
+
* surrounding infrastructure, laid out ready to hand to a registrar/host.
|
|
307
|
+
*
|
|
308
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "build-takedown-evidence-package")
|
|
309
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
310
|
+
* Inputs: domain.
|
|
311
|
+
* @see https://www.whisper.security/docs/recipes/threat-intel
|
|
312
|
+
*/
|
|
313
|
+
buildTakedownEvidencePackage(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Map an organisation's externally visible AI and agent endpoints from the outside.
|
|
317
|
+
*
|
|
318
|
+
* Maps externally visible AI/agent hosts (API, model, agent) from the outside via heuristic
|
|
319
|
+
* hostname patterns (api./mcp./ai./vector./llm./agent./chat./copilot.). Best-effort leads.
|
|
320
|
+
*
|
|
321
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "discover-ai-agent-infrastructure")
|
|
322
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
323
|
+
* Inputs: value.
|
|
324
|
+
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
325
|
+
*/
|
|
326
|
+
discoverAiAgentInfrastructure(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Investigate one suspicious domain, IP, or network in depth and get a clear picture of the threat and everything connected to it.
|
|
330
|
+
*
|
|
331
|
+
* Deep-dive: works outward across the whole footprint (related domains, real origins behind
|
|
332
|
+
* CDN, neighbouring infra), threat-checks each node, never inherits maliciousness from shared
|
|
333
|
+
* infra; labelled posture, no score.
|
|
334
|
+
*
|
|
335
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "indicator")
|
|
336
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
337
|
+
* Inputs: indicator.
|
|
338
|
+
* @see https://www.whisper.security/docs/recipes/soc
|
|
339
|
+
*/
|
|
340
|
+
indicator(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Turn one domain or IP into a full context card - owner, hosting, mail, location, reputation at a glance.
|
|
344
|
+
*
|
|
345
|
+
* Fills the picture for one indicator: registrant (WHOIS), hosting + country, mail/name
|
|
346
|
+
* servers, network behind it, reputation read.
|
|
347
|
+
*
|
|
348
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "indicator-enrichment")
|
|
349
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
350
|
+
* Inputs: value.
|
|
351
|
+
* @see https://www.whisper.security/docs/recipes/dns-email
|
|
352
|
+
*/
|
|
353
|
+
indicatorEnrichment(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Trace one indicator to its true owner and full estate, even behind privacy screens and CDNs.
|
|
357
|
+
*
|
|
358
|
+
* Works out the true operator (even behind privacy WHOIS), de-cloaks CDN-fronted sites to real
|
|
359
|
+
* servers, pivots to the rest of that owner's estate across every layer.
|
|
360
|
+
*
|
|
361
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "infrastructure-mapping")
|
|
362
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
363
|
+
* Inputs: value. Params: level.
|
|
364
|
+
* @see https://www.whisper.security/docs/recipes/compliance
|
|
365
|
+
*/
|
|
366
|
+
infrastructureMapping(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Grade an organisation for over-reliance on single providers, regions, or facilities.
|
|
370
|
+
*
|
|
371
|
+
* Grades how concentrated infra is - too much riding on one provider/region/data-centre/cable
|
|
372
|
+
* landing - surfacing SPOFs for resilience and DORA/NIS2 fourth-party risk.
|
|
373
|
+
*
|
|
374
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "map-supply-chain-concentration")
|
|
375
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
376
|
+
* Inputs: domain.
|
|
377
|
+
* @see https://www.whisper.security/docs/recipes/compliance
|
|
378
|
+
*/
|
|
379
|
+
mapSupplyChainConcentration(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Check a domain's name servers for the misconfigurations that enable DNS hijacking.
|
|
383
|
+
*
|
|
384
|
+
* Audits delegation, flags stale/mismatched/lame nameservers, sizes each provider's share,
|
|
385
|
+
* surfaces registry facts - catches delegation weakness before exploit.
|
|
386
|
+
*
|
|
387
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "nameserver-hijack-dns-consistency")
|
|
388
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
389
|
+
* Inputs: value.
|
|
390
|
+
* @see https://www.whisper.security/docs/recipes/dns-email
|
|
391
|
+
*/
|
|
392
|
+
nameserverHijackDnsConsistency(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Profile a network or address block into a full routing and reachability health card.
|
|
396
|
+
*
|
|
397
|
+
* Health card for a network/block: what it announces, peers/transit, single-upstream lean,
|
|
398
|
+
* RPKI protection - the one-look reach & resilience report.
|
|
399
|
+
*
|
|
400
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "route-health")
|
|
401
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
402
|
+
* Inputs: target.
|
|
403
|
+
* @see https://www.whisper.security/docs/recipes/bgp-routing
|
|
404
|
+
*/
|
|
405
|
+
routeHealth(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Find subdomains that point at abandoned services an attacker could claim.
|
|
409
|
+
*
|
|
410
|
+
* Walks subdomains and flags ones aiming at deprovisioned targets (dangling CNAME whose target
|
|
411
|
+
* no longer resolves) so you can reclaim/remove before someone else does. CNAME layer is
|
|
412
|
+
* prod-ahead.
|
|
413
|
+
*
|
|
414
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "subdomain-takeover")
|
|
415
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
416
|
+
* Inputs: value.
|
|
417
|
+
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
418
|
+
*/
|
|
419
|
+
subdomainTakeover(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Find registered look-alikes of your brand and check which ones are dangerous.
|
|
423
|
+
*
|
|
424
|
+
* Finds registered impersonations across misspellings/risky TLDs, separates your own defensive
|
|
425
|
+
* domains from strangers', flags fresh/privacy/abuse-listed ones into a prioritised list.
|
|
426
|
+
*
|
|
427
|
+
* KEYED flow: streams Server-Sent Events from the workflow runner (slug "typosquat")
|
|
428
|
+
* and resolves to the collected { slug, steps, complete, events }.
|
|
429
|
+
* Inputs: domain.
|
|
430
|
+
* @see https://www.whisper.security/docs/recipes/brand-protection
|
|
431
|
+
*/
|
|
432
|
+
typosquat(input?: GraphFlowInput, opts?: GraphOptions): Promise<GraphFlowResult>;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Name the vendor and operator role behind a host or IP in one call.
|
|
436
|
+
*
|
|
437
|
+
* Resolves a host/IP to its canonical vendor, category and operator roles (DNS_OPERATOR / CDN
|
|
438
|
+
* / ORIGIN_AS / MAIL_RECEIVER) with a confidence band.
|
|
439
|
+
*
|
|
440
|
+
* KEYED. Cypher: CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band
|
|
441
|
+
* Returns columns: host, vendor_id, canonical_name, category, roles, host_class, band.
|
|
442
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/identify
|
|
443
|
+
*/
|
|
444
|
+
identify(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Get a labelled threat posture for a host or IP - malicious, benign, or unknown.
|
|
448
|
+
*
|
|
449
|
+
* Returns a posture label + severity band + sub-labels + coverage + evidence for an indicator;
|
|
450
|
+
* benign-allowlisted vs malicious-evidenced, never a bare score.
|
|
451
|
+
*
|
|
452
|
+
* KEYED. Cypher: CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence
|
|
453
|
+
* Returns columns: host, label, band, sub_labels, coverage, evidence.
|
|
454
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures
|
|
455
|
+
*/
|
|
456
|
+
assess(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Generate look-alike domain variants of a brand and see which are registered.
|
|
460
|
+
*
|
|
461
|
+
* Enumerates permutations (omission, bitsquatting, ...) of a domain, flags which exist, with a
|
|
462
|
+
* per-variant confidence.
|
|
463
|
+
*
|
|
464
|
+
* KEYED. Cypher: CALL whisper.variants($v) YIELD variant, method, exists, confidence
|
|
465
|
+
* Returns columns: variant, method, exists, confidence.
|
|
466
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/variants
|
|
467
|
+
*/
|
|
468
|
+
variants(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Walk the graph to the nearest known vendors behind a host, with the channel and confidence.
|
|
472
|
+
*
|
|
473
|
+
* Structural attribution: returns nearest_known_vendors with the channel (DELEGATED_TO /
|
|
474
|
+
* ORIGIN_AS) and confidence, plus siblings and coverage - explains WHY a host maps to a
|
|
475
|
+
* vendor.
|
|
476
|
+
*
|
|
477
|
+
* KEYED. Cypher: CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings
|
|
478
|
+
* Returns columns: coverage, host, nearest_known_vendors, no_atlas_match, siblings.
|
|
479
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures
|
|
480
|
+
*/
|
|
481
|
+
walk(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Score an indicator against the threat feeds and explain exactly why.
|
|
485
|
+
*
|
|
486
|
+
* Threat-intel scorer: score + level + human explanation + the feeds it is listed in (feedId,
|
|
487
|
+
* weight, firstSeen/lastSeen). Two live forms: bare CALL explain($v) (full columns) and CALL
|
|
488
|
+
* whisper.explain($v) (restricted YIELD).
|
|
489
|
+
*
|
|
490
|
+
* KEYED. Cypher: CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources
|
|
491
|
+
* Returns columns: indicator, score, level, explanation, sources.
|
|
492
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/explain
|
|
493
|
+
*/
|
|
494
|
+
explain(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Reduce any hostname to its registrable apex (eTLD+1) via the Public Suffix List.
|
|
498
|
+
*
|
|
499
|
+
* PSL-correct eTLD+1: www.foo.co.uk -> foo.co.uk. The right way to group hosts by the thing
|
|
500
|
+
* someone actually registered.
|
|
501
|
+
*
|
|
502
|
+
* KEYED. Cypher: CALL whisper.psl.tldPlusOne($v) YIELD apex
|
|
503
|
+
* Returns columns: apex.
|
|
504
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
|
|
505
|
+
*/
|
|
506
|
+
pslTldplusone(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Check whether a domain is a PSL private-section suffix and who submitted it.
|
|
510
|
+
*
|
|
511
|
+
* For a PSL private-section suffix, returns the submitting org/login and evidence kind +
|
|
512
|
+
* confidence; found=false for ordinary registrable domains.
|
|
513
|
+
*
|
|
514
|
+
* KEYED. Cypher: CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence
|
|
515
|
+
* Returns columns: found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence.
|
|
516
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
|
|
517
|
+
*/
|
|
518
|
+
pslAffiliation(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Find the real origin IPs behind a CDN-fronted domain, ranked by confidence.
|
|
522
|
+
*
|
|
523
|
+
* Candidate origin IPs behind a CDN with confidence, the methods that found them (e.g.
|
|
524
|
+
* links_to), and the hosting ASN/name - de-cloaks to the real server.
|
|
525
|
+
*
|
|
526
|
+
* KEYED. Cypher: CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind
|
|
527
|
+
* Returns columns: ip, confidence, methods, asn, asnName, kind.
|
|
528
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/origins
|
|
529
|
+
*/
|
|
530
|
+
origins(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Get the full historical WHOIS timeline for a domain.
|
|
534
|
+
*
|
|
535
|
+
* Every observed WHOIS snapshot over time: create/update/expiry, registrar, registrant,
|
|
536
|
+
* country, nameservers - the ownership/registration story.
|
|
537
|
+
*
|
|
538
|
+
* KEYED. Cypher: CALL whisper.history($v)
|
|
539
|
+
* Returns columns: indicator, type, queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers, cached.
|
|
540
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/history
|
|
541
|
+
*/
|
|
542
|
+
history(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Get the WHOIS-only historical timeline for a domain.
|
|
546
|
+
*
|
|
547
|
+
* The registration-record projection of the history timeline: create/update/expiry, registrar,
|
|
548
|
+
* registrant, country, nameservers per snapshot.
|
|
549
|
+
*
|
|
550
|
+
* KEYED. Cypher: CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers
|
|
551
|
+
* Returns columns: queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers.
|
|
552
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/history
|
|
553
|
+
*/
|
|
554
|
+
historyWhois(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* List the member ASNs of an AS-SET macro.
|
|
558
|
+
*
|
|
559
|
+
* Expands an AS-SET name (e.g. AS-CLOUDFLARE) to its member ASNs and source RIR. Arg is a
|
|
560
|
+
* STRING as-set name, not an integer ASN. (Membership is sparsely populated in the current
|
|
561
|
+
* graph.)
|
|
562
|
+
*
|
|
563
|
+
* KEYED. Cypher: CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir
|
|
564
|
+
* Returns columns: asSetName, memberAsn, sourceRir.
|
|
565
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures
|
|
566
|
+
*/
|
|
567
|
+
asset(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Check whether an IP is a known Tor exit relay.
|
|
571
|
+
*
|
|
572
|
+
* Live Tor exit-node check: found + fingerprint + exit-address count + source + ingest time
|
|
573
|
+
* for an IPv4.
|
|
574
|
+
*
|
|
575
|
+
* KEYED. Cypher: CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt
|
|
576
|
+
* Returns columns: indicator, found, fingerprint, exitAddressCount, source, ingestedAt.
|
|
577
|
+
* @see https://www.whisper.security/docs/whisper-graph/procedures/helpers
|
|
578
|
+
*/
|
|
579
|
+
lookupTorRelay(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* List every node and relationship type in the graph with counts and examples.
|
|
583
|
+
*
|
|
584
|
+
* The self-describing schema: node/relationship types with counts, descriptions, examples,
|
|
585
|
+
* source/target labels and query best-practices - the map of what the graph holds.
|
|
586
|
+
*
|
|
587
|
+
* KEYED. Cypher: CALL db.schema()
|
|
588
|
+
* Returns columns: type, name, count, description, example, sourceLabels, targetLabels, fastPatterns, slowPatterns, bestPractices.
|
|
589
|
+
* @see https://www.whisper.security/docs/whisper-graph/schema
|
|
590
|
+
*/
|
|
591
|
+
dbSchema(opts?: GraphOptions): Promise<GraphResult>;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Contribute an indicator observation or feedback back into the graph (requires an API key).
|
|
595
|
+
*
|
|
596
|
+
* The write channel: submit an indicator/feedback with an attributable API key (anonymous
|
|
597
|
+
* submits are refused to preserve K-anonymity). Keyed-only by design.
|
|
598
|
+
*
|
|
599
|
+
* KEYED write: CALL whisper.submit({...}) with the payload fields bound as $-parameters.
|
|
600
|
+
* Fields: kind, identifier_kind, value, observation_id, confidence, first_seen, provenance, query, results, comment, severity, v (kind required).
|
|
601
|
+
* Returns columns: observation_id, kind, accepted, idempotent, promotion_state, k_bucket, advisory, v.
|
|
602
|
+
* @see https://www.whisper.security/docs/cypher-api
|
|
603
|
+
*/
|
|
604
|
+
submit(payload: GraphSubmission, opts?: GraphOptions): Promise<GraphResult>;
|
|
605
|
+
// END GENERATED: graph catalog
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The keyed graph namespace. `import { graph } from "whisper-id"`. */
|
|
609
|
+
export const graph: GraphNamespace;
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (c) 2026 viaGraph B.V. (Whisper Security)
|
|
3
|
-
// whisper-id
|
|
3
|
+
// whisper-id - a routable IPv6 identity and safe egress for any Node agent.
|
|
4
4
|
// A thin, dependency-free wrapper over the `whisper` CLI (https://whisper.online): the CLI
|
|
5
5
|
// holds the auth, control plane, and egress tunnel; this gives you register/egress/verify/ip.
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
@@ -72,7 +72,7 @@ function getKey(obj, ...keys) {
|
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Create a named agent identity
|
|
75
|
+
/** Create a named agent identity - a routable Whisper IPv6 /128. */
|
|
76
76
|
export async function register(name, { newKey = false, timeout = 120000 } = {}) {
|
|
77
77
|
if (!name || !name.trim()) throw new WhisperError("register() needs a non-empty agent name");
|
|
78
78
|
const args = ["create", "--name", name];
|
|
@@ -129,7 +129,7 @@ export async function withEgress(optsOrFn, maybeFn) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Keyless public endpoint base (the same surface the CLI uses, server-side). No key, no `whisper`
|
|
132
|
-
// CLI
|
|
132
|
+
// CLI - these work in any runtime that can fetch() (serverless, edge, browser). Override via env.
|
|
133
133
|
function rdapBase() {
|
|
134
134
|
return (process.env.WHISPER_RDAP_URL || "https://rdap.whisper.online").replace(/\/+$/, "");
|
|
135
135
|
}
|
|
@@ -155,7 +155,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* Return true iff `address` is a real Whisper agent. **Keyless and CLI-free**
|
|
158
|
+
* Return true iff `address` is a real Whisper agent. **Keyless and CLI-free** - one HTTPS GET to
|
|
159
159
|
* the public /verify-identity endpoint (server-side DANE+DNSSEC+reverse-DNS+JWS chain). Works in
|
|
160
160
|
* any runtime. 200 ⇒ agent, 404 ⇒ not. Throws only if the endpoint is unreachable.
|
|
161
161
|
*/
|
|
@@ -163,7 +163,7 @@ export async function verify(address, { timeout = 60000 } = {}) {
|
|
|
163
163
|
return (await verifyDetails(address, { timeout })) !== null;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/** Full verify verdict for a real agent (is_whisper_agent, fqdn, operator, tenant, dane_ok, jws_ok,
|
|
166
|
+
/** Full verify verdict for a real agent (is_whisper_agent, fqdn, operator, tenant, dane_ok, jws_ok, ...), else null. Keyless, CLI-free. */
|
|
167
167
|
export async function verifyDetails(address, { timeout = 60000 } = {}) {
|
|
168
168
|
if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
|
|
169
169
|
const url = `${rdapBase()}/verify-identity?ip=${encodeURIComponent(address.trim())}`;
|
|
@@ -172,10 +172,10 @@ export async function verifyDetails(address, { timeout = 60000 } = {}) {
|
|
|
172
172
|
return body;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
/** Public RDAP record for a Whisper /128 (handle, name, status, entities,
|
|
175
|
+
/** Public RDAP record for a Whisper /128 (handle, name, status, entities, ...), or null. Keyless, CLI-free. */
|
|
176
176
|
export async function rdap(address, { timeout = 60000 } = {}) {
|
|
177
177
|
if (!address || !address.trim()) throw new WhisperError("rdap() needs an address");
|
|
178
|
-
const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment
|
|
178
|
+
const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment - keep the literal colons
|
|
179
179
|
const { status, body } = await httpGetJson(url, { timeout });
|
|
180
180
|
return status === 200 ? body : null;
|
|
181
181
|
}
|
|
@@ -193,10 +193,10 @@ export async function ip({ timeout = 60000 } = {}) {
|
|
|
193
193
|
return getKey(env, "ip", "agent") || firstAddr(env) || "";
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// ── Control plane (pure HTTP
|
|
196
|
+
// ── Control plane (pure HTTP - no CLI) ──────────────────────────────────────────────
|
|
197
197
|
// The governance half of the two-tier surface (Postel: keyless verify above works with no
|
|
198
|
-
// key; these unlock with the caller's key). One control verb
|
|
199
|
-
//
|
|
198
|
+
// key; these unlock with the caller's key). One control verb - `whisper.agents({op,args})`
|
|
199
|
+
// - POSTed straight to the control endpoint. No `whisper` CLI, no child process, no key on
|
|
200
200
|
// argv: works in any runtime with fetch() (serverless, edge, a plain server). The key is
|
|
201
201
|
// read from opts.apiKey or $WHISPER_API_KEY and sent ONLY in the X-API-Key header.
|
|
202
202
|
|
|
@@ -204,8 +204,8 @@ function controlUrl() {
|
|
|
204
204
|
return process.env.WHISPER_CONTROL_URL || "https://graph.whisper.security/api/query";
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
// Conservative-emit (RFC 761): render a value as a Cypher literal so no input
|
|
208
|
-
// hostile
|
|
207
|
+
// Conservative-emit (RFC 761): render a value as a Cypher literal so no input - however
|
|
208
|
+
// hostile - can break out of the map. A ' is doubled and a \ is doubled (order matters:
|
|
209
209
|
// backslashes first). Mirrors the reference client's cypher builder byte-for-byte.
|
|
210
210
|
function escapeCypher(s) {
|
|
211
211
|
return String(s).replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
@@ -235,8 +235,8 @@ function buildAgentsQuery(op, args) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
// Liberal-accept (RFC 761): decode BOTH documented envelope shapes into one normalised
|
|
238
|
-
// { ok, status, result, error, retryAfter }
|
|
239
|
-
// AND the live procedure-row table whose single row carries the per-op envelope
|
|
238
|
+
// { ok, status, result, error, retryAfter } - the flat {ok,status,result,error} dev shape
|
|
239
|
+
// AND the live procedure-row table whose single row carries the per-op envelope - plus a
|
|
240
240
|
// bare RFC-7807 problem object. Never throws on shape; a fault becomes error, not a crash.
|
|
241
241
|
function decodeEnvelope(body, httpStatus) {
|
|
242
242
|
const out = { ok: false, status: httpStatus, result: null, error: null, retryAfter: null };
|
|
@@ -254,9 +254,9 @@ function decodeEnvelope(body, httpStatus) {
|
|
|
254
254
|
if (out.error && !out.error.status) out.error.status = out.status;
|
|
255
255
|
return out;
|
|
256
256
|
};
|
|
257
|
-
// Shape B
|
|
257
|
+
// Shape B - an explicit ok flag at the top level.
|
|
258
258
|
if (typeof body.ok === "boolean") return applyRow(body);
|
|
259
|
-
// Shape A
|
|
259
|
+
// Shape A - the procedure-row table: rows[0] carries {op,ok,status,result,error,...}. The
|
|
260
260
|
// row may be an object already, or a positional array aligned to top-level columns.
|
|
261
261
|
if (Array.isArray(body.rows) && body.rows.length > 0) {
|
|
262
262
|
let row0 = body.rows[0];
|
|
@@ -287,7 +287,7 @@ function decodeEnvelope(body, httpStatus) {
|
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
// Turn a {columns,rows} result into column-keyed records. Rows may be positional arrays
|
|
290
|
-
// (aligned to columns) or already-keyed objects
|
|
290
|
+
// (aligned to columns) or already-keyed objects - both are accepted.
|
|
291
291
|
function recordsFrom(result) {
|
|
292
292
|
if (!result || !Array.isArray(result.rows)) return [];
|
|
293
293
|
const cols = Array.isArray(result.columns) ? result.columns : [];
|
|
@@ -312,7 +312,7 @@ async function callControl(op, args, opts = {}) {
|
|
|
312
312
|
const key = resolveKey(opts);
|
|
313
313
|
if (!key) {
|
|
314
314
|
throw new WhisperError(
|
|
315
|
-
`\`${op}\` needs a Whisper API key
|
|
315
|
+
`\`${op}\` needs a Whisper API key - pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
316
316
|
"(verify/verifyDetails/rdap are keyless and need no key.)"
|
|
317
317
|
);
|
|
318
318
|
}
|
|
@@ -383,7 +383,7 @@ export async function policy(opts = {}) {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
/**
|
|
386
|
-
* Query recent activity from warm storage (op:logs)
|
|
386
|
+
* Query recent activity from warm storage (op:logs) - the poll counterpart of the live
|
|
387
387
|
* monitor. All filters optional: `{ agent, kind:"dns"|"conn"|"alloc", from, to, limit }`
|
|
388
388
|
* (from/to accept epoch-ms, RFC-3339, or relative like "-1h"). → an array of event records.
|
|
389
389
|
*/
|
|
@@ -399,9 +399,9 @@ export async function logs(opts = {}) {
|
|
|
399
399
|
}
|
|
400
400
|
|
|
401
401
|
/**
|
|
402
|
-
* Fully revoke an agent
|
|
403
|
-
* `revoke("agent-id")`, `revoke("2a04:2a01
|
|
404
|
-
* → `{ status,
|
|
402
|
+
* Fully revoke an agent - IRREVERSIBLE (op:revoke; needs admin:dns). Liberal input:
|
|
403
|
+
* `revoke("agent-id")`, `revoke("2a04:2a01:...")`, or `revoke({ agent, apiKey })`.
|
|
404
|
+
* → `{ status, ... }`.
|
|
405
405
|
*/
|
|
406
406
|
export async function revoke(target, maybeOpts) {
|
|
407
407
|
const o = typeof target === "string" ? maybeOpts || {} : target || {};
|
|
@@ -414,7 +414,7 @@ export async function revoke(target, maybeOpts) {
|
|
|
414
414
|
|
|
415
415
|
/**
|
|
416
416
|
* Allocate your own /128 identity (op:identity): `identity({ label, contact_email })`.
|
|
417
|
-
* Release form: `identity({ release:true, address:"2a04:2a01
|
|
417
|
+
* Release form: `identity({ release:true, address:"2a04:2a01:..." })`.
|
|
418
418
|
* → `{ agent, address, fqdn, ptr, state }`.
|
|
419
419
|
*/
|
|
420
420
|
export async function identity(opts = {}) {
|
|
@@ -434,7 +434,7 @@ export async function identity(opts = {}) {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
/**
|
|
437
|
-
* One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01
|
|
437
|
+
* One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01:...")`,
|
|
438
438
|
* `agent("agent-id")`, or `agent({ agent })` / `agent({ address })`. A value with a colon
|
|
439
439
|
* is treated as a /128 address. → the detail record, or null if not in your account.
|
|
440
440
|
*/
|
|
@@ -454,3 +454,380 @@ export async function agent(sel = {}, maybeOpts) {
|
|
|
454
454
|
const recs = await callControl("agent", args, o);
|
|
455
455
|
return recs.length ? recs[0] : null;
|
|
456
456
|
}
|
|
457
|
+
|
|
458
|
+
// ── Graph (keyed Cypher over the Whisper Security graph) ─────────────────────────────
|
|
459
|
+
// The intelligence half of the keyed surface. Postel two-tier holds cleanly: the keyless
|
|
460
|
+
// identity calls above (verify/verifyDetails/rdap) run with NO key and touch NO Cypher;
|
|
461
|
+
// this whole `graph` namespace is KEYED (Kaveh's absolute rule: if it is Cypher, it needs
|
|
462
|
+
// a key). It POSTs raw Cypher to the SAME endpoint the control plane uses
|
|
463
|
+
// (https://graph.whisper.security/api/query, X-API-Key), reusing resolveKey() so there is
|
|
464
|
+
// one auth path, not two. It does NOT go through the whisper.agents() control wrapper: the
|
|
465
|
+
// graph contract is different (body {query,parameters}; reply {columns,rows,statistics}
|
|
466
|
+
// where rows are already column-keyed objects), so it has its own choke point, callGraph().
|
|
467
|
+
//
|
|
468
|
+
// Source of truth for the 29 methods is the Whisper query catalog (catalog.json +
|
|
469
|
+
// sdk-methods.json): the GRAPH_METHODS table below is GENERATED from it by
|
|
470
|
+
// scripts/gen-graph.mjs (`npm run gen`), so this SDK cannot drift from the catalog. The
|
|
471
|
+
// 14 `direct` entries run their catalog Cypher against /api/query; the 15 `flow` entries
|
|
472
|
+
// EXECUTE on the workflow runner (POST gallery/run, Server-Sent Events) via callFlow().
|
|
473
|
+
// Every method carries its canonical docs URL as `.docs`. graph.query() is the raw
|
|
474
|
+
// escape hatch.
|
|
475
|
+
|
|
476
|
+
function graphUrl() {
|
|
477
|
+
// Defaults to the control endpoint, which already IS the graph endpoint. Override for tests
|
|
478
|
+
// or a private deployment. WHISPER_GRAPH_URL wins, then the shared control URL.
|
|
479
|
+
return process.env.WHISPER_GRAPH_URL || controlUrl();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Normalise the /api/query reply into { columns, rows, statistics }. Per the contract rows
|
|
483
|
+
// are OBJECTS keyed by column name; we also accept a positional-array row (aligned to
|
|
484
|
+
// columns) so a liberal server shape still decodes. Never throws on shape.
|
|
485
|
+
function normalizeGraph(body) {
|
|
486
|
+
if (!body || typeof body !== "object") return { columns: [], rows: [], statistics: null };
|
|
487
|
+
const columns = Array.isArray(body.columns) ? body.columns : [];
|
|
488
|
+
const rawRows = Array.isArray(body.rows) ? body.rows : [];
|
|
489
|
+
const rows = rawRows.map((row) => {
|
|
490
|
+
if (Array.isArray(row)) {
|
|
491
|
+
const m = {};
|
|
492
|
+
columns.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
|
|
493
|
+
return m;
|
|
494
|
+
}
|
|
495
|
+
return row && typeof row === "object" ? row : { value: row };
|
|
496
|
+
});
|
|
497
|
+
return { columns, rows, statistics: body.statistics || null };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The one place that talks to the graph. Requires a key (via the shared resolveKey), POSTs
|
|
501
|
+
// {query,parameters}, and on an HTTP>=400 or an RFC-7807 problem body throws a WhisperError
|
|
502
|
+
// carrying the server's helpful detail (plus .status/.retryAfter). The key is only ever sent
|
|
503
|
+
// in X-API-Key: never logged, never on argv, never returned.
|
|
504
|
+
async function callGraph(cypher, params, opts = {}, methodName = "query") {
|
|
505
|
+
const key = resolveKey(opts);
|
|
506
|
+
if (!key) {
|
|
507
|
+
throw new WhisperError(
|
|
508
|
+
`\`graph.${methodName}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
509
|
+
"(verify/verifyDetails/rdap are keyless and need no key; graph queries are Cypher, so they do.)"
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const url = graphUrl();
|
|
513
|
+
const timeout = opts.timeout || 60000;
|
|
514
|
+
const ctrl = new AbortController();
|
|
515
|
+
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
516
|
+
let res;
|
|
517
|
+
try {
|
|
518
|
+
res = await fetch(url, {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: {
|
|
521
|
+
"Content-Type": "application/json",
|
|
522
|
+
Accept: "application/json",
|
|
523
|
+
"X-API-Key": key,
|
|
524
|
+
"User-Agent": UA,
|
|
525
|
+
},
|
|
526
|
+
body: JSON.stringify({ query: cypher, parameters: params || {} }),
|
|
527
|
+
signal: ctrl.signal,
|
|
528
|
+
});
|
|
529
|
+
} catch (err) {
|
|
530
|
+
throw new WhisperError(`could not reach the Whisper graph at ${url}: ${err && err.message ? err.message : err}`);
|
|
531
|
+
} finally {
|
|
532
|
+
clearTimeout(t);
|
|
533
|
+
}
|
|
534
|
+
let body = null;
|
|
535
|
+
try {
|
|
536
|
+
body = await res.json();
|
|
537
|
+
} catch {
|
|
538
|
+
body = null;
|
|
539
|
+
}
|
|
540
|
+
// A fault is either an HTTP error status or a problem body (has error/detail/title but no
|
|
541
|
+
// result table). A valid table always carries columns/rows, so those are never a fault.
|
|
542
|
+
const hasTable = body && typeof body === "object" && (Array.isArray(body.columns) || Array.isArray(body.rows));
|
|
543
|
+
const problem = body && typeof body === "object" && (body.error || (!hasTable && (body.detail || body.title || body.type)));
|
|
544
|
+
if (res.status >= 400 || problem) {
|
|
545
|
+
const e = (body && (body.error || body)) || {};
|
|
546
|
+
const werr = new WhisperError(e.detail || e.title || e.message || e.type || `graph query returned HTTP ${res.status}`);
|
|
547
|
+
werr.status = e.status || res.status;
|
|
548
|
+
if (e.retry_after != null) werr.retryAfter = e.retry_after;
|
|
549
|
+
throw werr;
|
|
550
|
+
}
|
|
551
|
+
return normalizeGraph(body);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// BEGIN GENERATED: graph catalog (scripts/gen-graph.mjs) - do not edit by hand
|
|
555
|
+
// The Whisper query catalog, one row per entry (14 direct + 15 flow), in catalog order.
|
|
556
|
+
// A direct row carries its catalog Cypher (input bound as a $-parameter); a flow row
|
|
557
|
+
// carries its gallery/run slug plus the input/param names the workflow runner accepts;
|
|
558
|
+
// the map-call row (submit) enumerates its fields. `docs` is the canonical docs URL.
|
|
559
|
+
const GRAPH_DOCS_BASE = "https://www.whisper.security";
|
|
560
|
+
const GRAPH_FLOW_RUN_URL = "https://console.whisper.security/api/gallery/run";
|
|
561
|
+
const GRAPH_METHODS = [
|
|
562
|
+
{ name: "anycastDnsRootSovereignty", mode: "flow", slug: "anycast-dns-root-sovereignty", primary: "country", inputs: ["country"], params: ["instanceType"], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
|
|
563
|
+
{ name: "attackPath", mode: "flow", slug: "attack-path", primary: "value", inputs: ["value", "other"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/attack-path" },
|
|
564
|
+
{ name: "attackSurface", mode: "flow", slug: "attack-surface", primary: "domain", inputs: ["domain"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
|
|
565
|
+
{ name: "bgpHijackExposure", mode: "flow", slug: "bgp-hijack-exposure", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
|
|
566
|
+
{ name: "blastRadius", mode: "flow", slug: "blast-radius", primary: "indicator", inputs: ["indicator"], params: ["depth"], docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
|
|
567
|
+
{ name: "buildTakedownEvidencePackage", mode: "flow", slug: "build-takedown-evidence-package", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/threat-intel" },
|
|
568
|
+
{ name: "discoverAiAgentInfrastructure", mode: "flow", slug: "discover-ai-agent-infrastructure", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
|
|
569
|
+
{ name: "indicator", mode: "flow", slug: "indicator", primary: "indicator", inputs: ["indicator"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/soc" },
|
|
570
|
+
{ name: "indicatorEnrichment", mode: "flow", slug: "indicator-enrichment", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
|
|
571
|
+
{ name: "infrastructureMapping", mode: "flow", slug: "infrastructure-mapping", primary: "value", inputs: ["value"], params: ["level"], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
|
|
572
|
+
{ name: "mapSupplyChainConcentration", mode: "flow", slug: "map-supply-chain-concentration", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/compliance" },
|
|
573
|
+
{ name: "nameserverHijackDnsConsistency", mode: "flow", slug: "nameserver-hijack-dns-consistency", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/dns-email" },
|
|
574
|
+
{ name: "routeHealth", mode: "flow", slug: "route-health", primary: "target", inputs: ["target"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/bgp-routing" },
|
|
575
|
+
{ name: "subdomainTakeover", mode: "flow", slug: "subdomain-takeover", primary: "value", inputs: ["value"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/pentest-recon" },
|
|
576
|
+
{ name: "typosquat", mode: "flow", slug: "typosquat", primary: "domain", inputs: ["domain"], params: [], docs: GRAPH_DOCS_BASE + "/docs/recipes/brand-protection" },
|
|
577
|
+
{ name: "identify", mode: "direct", input: "v", cypher: "CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/identify" },
|
|
578
|
+
{ name: "assess", mode: "direct", input: "v", cypher: "CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
|
|
579
|
+
{ name: "variants", mode: "direct", input: "v", cypher: "CALL whisper.variants($v) YIELD variant, method, exists, confidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/variants" },
|
|
580
|
+
{ name: "walk", mode: "direct", input: "v", cypher: "CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
|
|
581
|
+
{ name: "explain", mode: "direct", input: "v", cypher: "CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/explain" },
|
|
582
|
+
{ name: "pslTldplusone", mode: "direct", input: "v", cypher: "CALL whisper.psl.tldPlusOne($v) YIELD apex", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
|
|
583
|
+
{ name: "pslAffiliation", mode: "direct", input: "v", cypher: "CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
|
|
584
|
+
{ name: "origins", mode: "direct", input: "v", cypher: "CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/origins" },
|
|
585
|
+
{ name: "history", mode: "direct", input: "v", cypher: "CALL whisper.history($v)", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
|
|
586
|
+
{ name: "historyWhois", mode: "direct", input: "v", cypher: "CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/history" },
|
|
587
|
+
{ name: "asset", mode: "direct", input: "v", cypher: "CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures" },
|
|
588
|
+
{ name: "lookupTorRelay", mode: "direct", input: "v", cypher: "CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/procedures/helpers" },
|
|
589
|
+
{ name: "dbSchema", mode: "direct", input: null, cypher: "CALL db.schema()", docs: GRAPH_DOCS_BASE + "/docs/whisper-graph/schema" },
|
|
590
|
+
{ name: "submit", mode: "direct", proc: "whisper.submit", fields: [["kind", "kind"], ["identifierKind", "identifier_kind"], ["value", "value"], ["observationId", "observation_id"], ["confidence", "confidence"], ["firstSeen", "first_seen"], ["provenance", "provenance"], ["query", "query"], ["results", "results"], ["comment", "comment"], ["severity", "severity"], ["v", "v"]], requires: ["kind"], docs: GRAPH_DOCS_BASE + "/docs/cypher-api" },
|
|
591
|
+
];
|
|
592
|
+
// END GENERATED: graph catalog
|
|
593
|
+
|
|
594
|
+
// Coerce a liberal first arg into the string value bound as $v. Accepts a bare value or a
|
|
595
|
+
// { value } / { v } wrapper; empty is rejected with a clear error, never an opaque failure.
|
|
596
|
+
function graphValue(name, value) {
|
|
597
|
+
let v = value;
|
|
598
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
599
|
+
if (v.value !== undefined) v = v.value;
|
|
600
|
+
else if (v.v !== undefined) v = v.v;
|
|
601
|
+
}
|
|
602
|
+
if (v === null || v === undefined || String(v).trim() === "") {
|
|
603
|
+
throw new WhisperError(`graph.${name}() needs a value (a host, IP, domain, or ASN)`);
|
|
604
|
+
}
|
|
605
|
+
return String(v).trim();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Run raw Cypher against the Whisper Security graph (keyed). The escape hatch under every
|
|
610
|
+
* typed method: `graph.query("MATCH (n) RETURN n LIMIT 1", {}, { apiKey })`. Returns the
|
|
611
|
+
* full `{ columns, rows, statistics }` (rows are objects keyed by column name).
|
|
612
|
+
* @see https://www.whisper.security/docs/cypher-api
|
|
613
|
+
*/
|
|
614
|
+
async function graphQuery(cypher, params, opts = {}) {
|
|
615
|
+
if (!cypher || !String(cypher).trim()) throw new WhisperError("graph.query() needs a Cypher string");
|
|
616
|
+
return callGraph(String(cypher), params || {}, opts, "query");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── Flow runner (gallery/run, Server-Sent Events) ───────────────────────────────────
|
|
620
|
+
// A flow entry is a multi-step read workflow orchestrated by the Whisper workflow runner:
|
|
621
|
+
// POST {slug, inputs, params} to the gallery/run endpoint (keyed, X-API-Key) and consume
|
|
622
|
+
// the SSE stream. Each `step` event carries a step's bound Cypher + rows; the terminal
|
|
623
|
+
// `complete` event carries the aggregate context. callFlow() collects the whole stream and
|
|
624
|
+
// resolves to { slug, steps, complete, events } - one call, the full flow.
|
|
625
|
+
|
|
626
|
+
function flowRunUrl() {
|
|
627
|
+
return process.env.WHISPER_FLOW_URL || GRAPH_FLOW_RUN_URL;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Liberal-accept (RFC 761): coerce the caller's input into the runner's {inputs, params}.
|
|
631
|
+
// A bare value binds to the flow's primary input; a plain object routes each key by name
|
|
632
|
+
// (a known knob name goes to params, everything else to inputs); an explicit
|
|
633
|
+
// { inputs, params } wrapper passes through untouched.
|
|
634
|
+
function flowBody(m, input) {
|
|
635
|
+
const inputs = {};
|
|
636
|
+
const params = {};
|
|
637
|
+
if (input === null || input === undefined) return { inputs, params };
|
|
638
|
+
if (typeof input !== "object" || Array.isArray(input)) {
|
|
639
|
+
const v = String(input).trim();
|
|
640
|
+
if (v === "") throw new WhisperError(`graph.${m.name}() needs a value for its \`${m.primary}\` input (a host, IP, domain, ASN, or prefix)`);
|
|
641
|
+
inputs[m.primary] = v;
|
|
642
|
+
return { inputs, params };
|
|
643
|
+
}
|
|
644
|
+
if ((input.inputs && typeof input.inputs === "object") || (input.params && typeof input.params === "object")) {
|
|
645
|
+
return { inputs: { ...(input.inputs || {}) }, params: { ...(input.params || {}) } };
|
|
646
|
+
}
|
|
647
|
+
for (const [k, v] of Object.entries(input)) {
|
|
648
|
+
if (v === undefined) continue;
|
|
649
|
+
if (m.params.includes(k)) params[k] = v;
|
|
650
|
+
else inputs[k] = v;
|
|
651
|
+
}
|
|
652
|
+
return { inputs, params };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Parse one SSE record ("event: x\ndata: {...}") into { event, data }; data is JSON-decoded
|
|
656
|
+
// when possible, else kept as the raw string. Never throws on shape.
|
|
657
|
+
function parseSseRecord(record) {
|
|
658
|
+
let event = "message";
|
|
659
|
+
const dataLines = [];
|
|
660
|
+
for (const line of record.split("\n")) {
|
|
661
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
662
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
663
|
+
}
|
|
664
|
+
let data = dataLines.join("\n");
|
|
665
|
+
try {
|
|
666
|
+
data = JSON.parse(data);
|
|
667
|
+
} catch {
|
|
668
|
+
// not JSON - keep the raw string
|
|
669
|
+
}
|
|
670
|
+
return { event, data };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// The one place that talks to the workflow runner. Requires a key (shared resolveKey),
|
|
674
|
+
// POSTs {slug, inputs, params}, consumes the SSE stream, and resolves to the collected
|
|
675
|
+
// { slug, steps, complete, events }. Flows are multi-step, so the default timeout is 5
|
|
676
|
+
// minutes (override with opts.timeout). On an HTTP error, an `error` event, or a stream
|
|
677
|
+
// that ends with no result at all, throws a WhisperError with the server's clear detail.
|
|
678
|
+
async function callFlow(m, input, opts = {}) {
|
|
679
|
+
const key = resolveKey(opts);
|
|
680
|
+
if (!key) {
|
|
681
|
+
throw new WhisperError(
|
|
682
|
+
`\`graph.${m.name}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
683
|
+
"(verify/verifyDetails/rdap are keyless and need no key; flows run keyed on the workflow runner.)"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
const { inputs, params } = flowBody(m, input);
|
|
687
|
+
const url = flowRunUrl();
|
|
688
|
+
const timeout = opts.timeout || 300000;
|
|
689
|
+
const ctrl = new AbortController();
|
|
690
|
+
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
691
|
+
try {
|
|
692
|
+
let res;
|
|
693
|
+
try {
|
|
694
|
+
res = await fetch(url, {
|
|
695
|
+
method: "POST",
|
|
696
|
+
headers: {
|
|
697
|
+
"Content-Type": "application/json",
|
|
698
|
+
Accept: "text/event-stream",
|
|
699
|
+
"X-API-Key": key,
|
|
700
|
+
"User-Agent": UA,
|
|
701
|
+
},
|
|
702
|
+
body: JSON.stringify({ slug: m.slug, inputs, params }),
|
|
703
|
+
signal: ctrl.signal,
|
|
704
|
+
});
|
|
705
|
+
} catch (err) {
|
|
706
|
+
throw new WhisperError(`could not reach the Whisper workflow runner at ${url}: ${err && err.message ? err.message : err}`);
|
|
707
|
+
}
|
|
708
|
+
if (res.status >= 400) {
|
|
709
|
+
let body = null;
|
|
710
|
+
try {
|
|
711
|
+
body = await res.json();
|
|
712
|
+
} catch {
|
|
713
|
+
body = null;
|
|
714
|
+
}
|
|
715
|
+
const e = body && typeof body === "object" ? body : {};
|
|
716
|
+
const werr = new WhisperError(
|
|
717
|
+
e.message || e.detail || (typeof e.error === "string" ? e.error : null) || `the workflow runner returned HTTP ${res.status}`
|
|
718
|
+
);
|
|
719
|
+
werr.status = res.status;
|
|
720
|
+
throw werr;
|
|
721
|
+
}
|
|
722
|
+
// Liberal accept: a plain-JSON reply (no stream) is decoded as the complete result.
|
|
723
|
+
const ct = res.headers && typeof res.headers.get === "function" ? res.headers.get("content-type") || "" : "";
|
|
724
|
+
if (ct.includes("json")) {
|
|
725
|
+
let body = null;
|
|
726
|
+
try {
|
|
727
|
+
body = await res.json();
|
|
728
|
+
} catch {
|
|
729
|
+
body = null;
|
|
730
|
+
}
|
|
731
|
+
return { slug: m.slug, steps: [], complete: body && typeof body === "object" ? body : null, events: [] };
|
|
732
|
+
}
|
|
733
|
+
// The SSE stream: split records on blank lines (CRLF-safe), decode each.
|
|
734
|
+
const events = [];
|
|
735
|
+
const decoder = new TextDecoder("utf-8");
|
|
736
|
+
let buf = "";
|
|
737
|
+
const drain = () => {
|
|
738
|
+
buf = buf.replace(/\r\n/g, "\n");
|
|
739
|
+
let i;
|
|
740
|
+
while ((i = buf.indexOf("\n\n")) !== -1) {
|
|
741
|
+
const rec = buf.slice(0, i);
|
|
742
|
+
buf = buf.slice(i + 2);
|
|
743
|
+
if (rec.trim() !== "") events.push(parseSseRecord(rec));
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
for await (const chunk of res.body) {
|
|
747
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
748
|
+
drain();
|
|
749
|
+
}
|
|
750
|
+
buf += decoder.decode();
|
|
751
|
+
drain();
|
|
752
|
+
if (buf.trim() !== "") events.push(parseSseRecord(buf));
|
|
753
|
+
const steps = events.filter((ev) => ev.event === "step").map((ev) => ev.data);
|
|
754
|
+
const completeEv = events.findLast((ev) => ev.event === "complete");
|
|
755
|
+
const complete = completeEv && completeEv.data && typeof completeEv.data === "object" ? completeEv.data : null;
|
|
756
|
+
const errEv = events.find((ev) => ev.event === "error");
|
|
757
|
+
if (errEv && !complete) {
|
|
758
|
+
const d = errEv.data && typeof errEv.data === "object" ? errEv.data : { detail: String(errEv.data || "") };
|
|
759
|
+
const werr = new WhisperError(d.message || d.detail || (typeof d.error === "string" ? d.error : null) || `graph.${m.name} failed on the workflow runner`);
|
|
760
|
+
werr.events = events;
|
|
761
|
+
throw werr;
|
|
762
|
+
}
|
|
763
|
+
if (!complete && steps.length === 0) {
|
|
764
|
+
const werr = new WhisperError(`graph.${m.name}: the flow stream ended without a step or complete event`);
|
|
765
|
+
werr.events = events;
|
|
766
|
+
throw werr;
|
|
767
|
+
}
|
|
768
|
+
return { slug: m.slug, steps, complete, events };
|
|
769
|
+
} catch (err) {
|
|
770
|
+
if (err instanceof WhisperError) throw err;
|
|
771
|
+
if ((err && err.name === "AbortError") || ctrl.signal.aborted) {
|
|
772
|
+
throw new WhisperError(`graph.${m.name} timed out after ${timeout}ms (flows are multi-step; raise { timeout } if you need longer)`);
|
|
773
|
+
}
|
|
774
|
+
throw new WhisperError(`graph.${m.name} failed while streaming from the workflow runner: ${err && err.message ? err.message : err}`);
|
|
775
|
+
} finally {
|
|
776
|
+
clearTimeout(t);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// The map-call write (whisper.submit): each payload field becomes one entry of a Cypher
|
|
781
|
+
// map with its value bound as its own $-parameter (injection-safe), keys in SORTED order
|
|
782
|
+
// so the query is byte-stable. Field names are accepted in both catalog spellings
|
|
783
|
+
// (camelCase id or snake_case paramName) and normalised to the wire form; any extra key
|
|
784
|
+
// must be a plain identifier, and the server validates the full set per kind.
|
|
785
|
+
function makeMapCall(m) {
|
|
786
|
+
const canon = new Map();
|
|
787
|
+
for (const [id, param] of m.fields) {
|
|
788
|
+
canon.set(id, param);
|
|
789
|
+
canon.set(param, param);
|
|
790
|
+
}
|
|
791
|
+
return async (payload, opts = {}) => {
|
|
792
|
+
const p = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
793
|
+
const bound = {};
|
|
794
|
+
for (const [k, v] of Object.entries(p)) {
|
|
795
|
+
if (v === undefined) continue;
|
|
796
|
+
const name = canon.get(k) || k;
|
|
797
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
798
|
+
throw new WhisperError(`graph.${m.name}() field ${JSON.stringify(k)} is not a valid field name (letters, digits, _)`);
|
|
799
|
+
}
|
|
800
|
+
bound[name] = v;
|
|
801
|
+
}
|
|
802
|
+
for (const req of m.requires) {
|
|
803
|
+
if (bound[req] === null || bound[req] === undefined || String(bound[req]).trim() === "") {
|
|
804
|
+
throw new WhisperError(`graph.${m.name}() needs a { ${req} } of 'indicator' or 'feedback' (plus its fields)`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const keys = Object.keys(bound).sort();
|
|
808
|
+
const cypher = `CALL ${m.proc}({` + keys.map((k) => `${k}:$${k}`).join(", ") + "})";
|
|
809
|
+
return callGraph(cypher, bound, opts, m.name);
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Build the namespace from the generated catalog table: query + one method per entry.
|
|
814
|
+
// Direct methods run their catalog Cypher; the map-call write builds its parameter map;
|
|
815
|
+
// flow methods execute on the workflow runner. Every method carries its canonical docs
|
|
816
|
+
// URL as `.docs` (docsBase + the entry's docPath).
|
|
817
|
+
const graph = { query: graphQuery };
|
|
818
|
+
graph.query.docs = GRAPH_DOCS_BASE + "/docs/cypher-api";
|
|
819
|
+
|
|
820
|
+
for (const m of GRAPH_METHODS) {
|
|
821
|
+
if (m.mode === "flow") {
|
|
822
|
+
graph[m.name] = async (input, opts = {}) => callFlow(m, input, opts);
|
|
823
|
+
} else if (m.fields) {
|
|
824
|
+
graph[m.name] = makeMapCall(m);
|
|
825
|
+
} else if (m.input) {
|
|
826
|
+
graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts, m.name);
|
|
827
|
+
} else {
|
|
828
|
+
graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts, m.name);
|
|
829
|
+
}
|
|
830
|
+
graph[m.name].docs = m.docs;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export { graph };
|
package/package.json
CHANGED
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whisper-id",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A routable Whisper IPv6 identity and safe egress for any Node agent.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"exports": {
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"import": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
7
12
|
"types": "./index.d.ts",
|
|
8
|
-
"files": [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"index.d.ts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test",
|
|
24
|
+
"gen": "node scripts/gen-graph.mjs"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"whisper",
|
|
28
|
+
"agent",
|
|
29
|
+
"identity",
|
|
30
|
+
"ipv6",
|
|
31
|
+
"egress",
|
|
32
|
+
"proxy",
|
|
33
|
+
"dns"
|
|
34
|
+
],
|
|
12
35
|
"homepage": "https://whisper.online",
|
|
13
|
-
"repository": {
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/whisper-sec/whisper-node.git"
|
|
39
|
+
},
|
|
14
40
|
"license": "MIT",
|
|
15
41
|
"author": "Whisper Security"
|
|
16
42
|
}
|