whisper-id 0.6.1 → 0.7.1
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 +14 -10
- package/SECURITY.md +26 -0
- package/index.d.ts +74 -42
- package/index.js +95 -40
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# whisper-id
|
|
2
2
|
|
|
3
|
-
Three things for any Node agent, in one dependency-free package: query the **Whisper security graph** (
|
|
3
|
+
Three things for any Node agent, in one dependency-free package: query the **Whisper security graph** (7.48B nodes and 39.5B edges joining the physical, network, naming, ownership and threat layers; live totals at nic.whisper.online/stats, Cypher), give the agent a routable **IPv6 identity** with safe **egress**, and drive the full **control plane**.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i whisper-id
|
|
@@ -8,7 +8,7 @@ npm i whisper-id
|
|
|
8
8
|
|
|
9
9
|
## The security graph (keyless, zero setup)
|
|
10
10
|
|
|
11
|
-
The Whisper graph knows who operates a host, its threat posture, its look-alikes, the real origins behind a CDN, WHOIS history, and
|
|
11
|
+
The Whisper graph knows who operates a host, its threat posture, its look-alikes, the real origins behind a CDN, WHOIS history, and 15 named investigations. The **direct read verbs run with no key at all** (rate-limited taste, ~100/window). One import, real answers:
|
|
12
12
|
|
|
13
13
|
```js
|
|
14
14
|
import { graph } from "whisper-id"; // no key needed for the read verbs
|
|
@@ -19,7 +19,7 @@ await graph.origins("cloudflare.com"); // the real origin IPs behind
|
|
|
19
19
|
await graph.explain("paypal.com"); // threat-feed score + why
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Set `WHISPER_API_KEY` (or pass `{ apiKey }`) to lift the rate limit
|
|
22
|
+
Set `WHISPER_API_KEY` (or pass `{ apiKey }`) to lift the keyless rate limit. Every verb is sent either way; the graph decides what to answer:
|
|
23
23
|
|
|
24
24
|
```js
|
|
25
25
|
// raw Cypher, your own query, parameters bound as $-params (never string-built):
|
|
@@ -35,7 +35,7 @@ for (const r of graph.recipes()) {
|
|
|
35
35
|
}
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Every verb maps to a catalog entry with its own docs page under [whisper.security/docs](https://www.whisper.security/docs) (e.g. [`assess`](https://www.whisper.security/docs/whisper-graph/procedures), [`identify`](https://www.whisper.security/docs/whisper-graph/procedures/identify)); `recipes()` carries the exact `docsUrl` for each, and the same URL sits on every method as `graph.identify.docs` and on its `@see` tag in your editor. The 13 direct reads
|
|
38
|
+
Every verb maps to a catalog entry with its own docs page under [whisper.security/docs](https://www.whisper.security/docs) (e.g. [`assess`](https://www.whisper.security/docs/whisper-graph/procedures), [`identify`](https://www.whisper.security/docs/whisper-graph/procedures/identify)); `recipes()` carries the exact `docsUrl` for each, and the same URL sits on every method as `graph.identify.docs` and on its `@see` tag in your editor. The 13 direct reads answer keyless; the 15 multi-step flows and the `submit` write channel are proxied up the same way and the graph decides. Full query reference: [whisper-catalog](https://github.com/whisper-sec/whisper-catalog).
|
|
39
39
|
|
|
40
40
|
## Identity + egress
|
|
41
41
|
|
|
@@ -78,12 +78,12 @@ if (await verify(addr)) console.log((await rdap(addr)).name); // keyless, pure
|
|
|
78
78
|
| `graph.asset(value)` | yes | Member ASNs of an AS-SET macro |
|
|
79
79
|
| `graph.lookupTorRelay(value)` | yes | Is an IP a known Tor exit relay |
|
|
80
80
|
| `graph.dbSchema()` | yes | Every node + relationship type in the graph, with counts and examples |
|
|
81
|
-
| `graph.query(cypher, params)` |
|
|
81
|
+
| `graph.query(cypher, params)` | yes | Raw Cypher escape hatch. -> the full `{ columns, rows, statistics }` |
|
|
82
82
|
| `graph.submit({ kind, ...fields })` | key | Contribute an indicator observation or feedback (the write channel) |
|
|
83
83
|
| `graph.recipes()` | yes (offline) | The whole catalog: `{ method, keyless, mode, summary, params, docsUrl }` per verb |
|
|
84
84
|
|
|
85
85
|
Fifteen multi-step **workflow** methods (`attackPath`, `attackSurface`, `blastRadius`, `typosquat`,
|
|
86
|
-
`indicator`, `routeHealth`, and 9 more) execute
|
|
86
|
+
`indicator`, `routeHealth`, and 9 more) execute on the Whisper workflow runner and stream
|
|
87
87
|
Server-Sent Events; one call collects the whole run into `{ slug, steps, complete, events }`:
|
|
88
88
|
|
|
89
89
|
```js
|
|
@@ -114,7 +114,10 @@ Run anywhere `fetch` runs (Cloudflare Workers, Vercel, Deno, Lambda, the browser
|
|
|
114
114
|
Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
|
|
115
115
|
`opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
|
|
116
116
|
never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
|
|
117
|
-
carrying the server's
|
|
117
|
+
carrying the server's message (plus `.code` / `.status` / `.retryable` / `.retryAfter`). A
|
|
118
|
+
`revoke` that matches nothing of yours (revoked already, or not in your account) throws
|
|
119
|
+
`NotFoundError` (`.code` `NOT_FOUND`, `.status` 404, not retryable): nothing was revoked, so
|
|
120
|
+
do not retry it.
|
|
118
121
|
|
|
119
122
|
| Call | Does |
|
|
120
123
|
|------|------|
|
|
@@ -165,14 +168,15 @@ transparently inside `withEgress`.
|
|
|
165
168
|
|
|
166
169
|
Zero dependencies. The **graph read verbs**, the **keyless** checks, and the **control plane** are
|
|
167
170
|
pure HTTP (Node ≥ 18's global `fetch`), with no CLI and nothing to install. Set `WHISPER_API_KEY`
|
|
168
|
-
(or pass `{ apiKey }`) for the control plane
|
|
169
|
-
|
|
171
|
+
(or pass `{ apiKey }`) for the control plane (`whisper.agents`), which always needs it. Graph calls
|
|
172
|
+
never require a key from this SDK: they go up with one or without, and the graph decides what to
|
|
173
|
+
answer. A key lifts the keyless rate limit. `verify` and `rdap` need no key either.
|
|
170
174
|
|
|
171
175
|
Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
|
|
172
176
|
`whisper` CLI on your `PATH`:
|
|
173
177
|
|
|
174
178
|
```sh
|
|
175
|
-
curl get.whisper.online | sh
|
|
179
|
+
curl -fsSL https://get.whisper.online | sh
|
|
176
180
|
```
|
|
177
181
|
|
|
178
182
|
(`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security issues privately to **security@whisper.security**. Do not
|
|
6
|
+
open a public issue for a suspected vulnerability.
|
|
7
|
+
|
|
8
|
+
Include what you found, how to reproduce it, and the impact you expect. We aim to
|
|
9
|
+
acknowledge within a few business days and will keep you updated as we work on a
|
|
10
|
+
fix. Coordinated disclosure is appreciated.
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
This repository is the **Node client** for Whisper: the control plane
|
|
15
|
+
(`whisper.agents`), the keyless public checks, and the Whisper security graph. It
|
|
16
|
+
talks only to Whisper's public endpoints and holds no server-side secrets.
|
|
17
|
+
|
|
18
|
+
Your API key travels in one place and one place only: the `X-API-Key` request
|
|
19
|
+
header. It is never written to a URL, a request body, a log line, or argv. A call
|
|
20
|
+
made without a key sends no auth header at all rather than an empty one. Any
|
|
21
|
+
deviation from that is a security bug and is in scope, as are the input validation
|
|
22
|
+
and the redaction of keys from error text.
|
|
23
|
+
|
|
24
|
+
## Supported versions
|
|
25
|
+
|
|
26
|
+
The latest published release receives security fixes. Older versions may not.
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Copyright (c) 2026 viaGraph B.V. (Whisper Security)
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a `whisper` invocation fails, the CLI is missing, or the control plane refuses
|
|
5
|
+
* a call. A control-plane refusal carries the server's `code`, `status` and `retryable`.
|
|
6
|
+
*/
|
|
7
|
+
export declare class WhisperError extends Error {
|
|
8
|
+
/** The server's error code, e.g. "NOT_FOUND" or "FORBIDDEN", when it gave one. */
|
|
9
|
+
code?: string;
|
|
10
|
+
/** The effective HTTP/envelope status of a control-plane refusal. */
|
|
11
|
+
status?: number;
|
|
12
|
+
/** Whether the server says retrying could succeed. */
|
|
13
|
+
retryable?: boolean;
|
|
14
|
+
/** Seconds the server asked the caller to wait (rate limits). */
|
|
15
|
+
retryAfter?: number;
|
|
16
|
+
suggestions?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Nothing of yours matched: from `revoke()`, nothing was revoked (revoked already, or not in
|
|
21
|
+
* your account). An answer, not a fault: never retry it.
|
|
22
|
+
*/
|
|
23
|
+
export declare class NotFoundError extends WhisperError {
|
|
24
|
+
code: "NOT_FOUND";
|
|
25
|
+
status: 404;
|
|
26
|
+
retryable: false;
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
export interface Agent {
|
|
6
30
|
/** The routable IPv6 /128 - both the identity and the auth. */
|
|
@@ -55,7 +79,8 @@ export function firstAddr(obj: unknown): string | null;
|
|
|
55
79
|
// The governance half of the two-tier surface: pure `fetch` to the control endpoint, so
|
|
56
80
|
// it runs anywhere (serverless, edge, a plain server). The key is read from `opts.apiKey`
|
|
57
81
|
// or `WHISPER_API_KEY` and sent only in the `X-API-Key` header. On failure these throw a
|
|
58
|
-
// `WhisperError` carrying the server's
|
|
82
|
+
// `WhisperError` carrying the server's message (and `.code` / `.status` / `.retryable` /
|
|
83
|
+
// `.retryAfter`); a `NotFoundError` when the server's code is NOT_FOUND.
|
|
59
84
|
|
|
60
85
|
/** Common options for every keyed control call. */
|
|
61
86
|
export interface ControlOptions {
|
|
@@ -142,7 +167,10 @@ export function list(opts?: ListOptions): Promise<AgentItem[]>;
|
|
|
142
167
|
export function policy(opts?: PolicyOptions): Promise<PolicyEntry[]>;
|
|
143
168
|
/** Query recent DNS/conn/alloc activity from warm storage. → event records. */
|
|
144
169
|
export function logs(opts?: LogsOptions): Promise<LogEvent[]>;
|
|
145
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Fully revoke an agent - IRREVERSIBLE (admin:dns). → `{ status, ... }`. Throws
|
|
172
|
+
* `NotFoundError` when nothing was revoked (revoked already, or not in your account).
|
|
173
|
+
*/
|
|
146
174
|
export function revoke(agent: string, opts?: ControlOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
147
175
|
export function revoke(opts: RevokeOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
148
176
|
/** Allocate your own /128 (or release with `{release:true,address}`). → `{agent,address,fqdn,ptr,state}`. */
|
|
@@ -157,7 +185,7 @@ export function agent(opts?: AgentSelectOptions): Promise<AgentItem | null>;
|
|
|
157
185
|
// KEYLESS - no key at all, a rate-limited taste with real answers; a key ({ apiKey } or
|
|
158
186
|
// WHISPER_API_KEY) lifts the limit. Raw Cypher (`graph.query`), the 15 multi-step `flow`
|
|
159
187
|
// methods (workflow runner: POST gallery/run, Server-Sent Events), and the `submit`
|
|
160
|
-
// write
|
|
188
|
+
// write are proxied up the same way. A present key rides only in `X-API-Key`; an absent one sends no auth
|
|
161
189
|
// header at all. The method surface below is GENERATED from the Whisper query catalog by
|
|
162
190
|
// scripts/gen-graph.mjs; every method's docs live at the `@see` URL and are also on the
|
|
163
191
|
// function itself as `.docs`, and `graph.recipes()` lists the whole catalog.
|
|
@@ -181,7 +209,7 @@ export interface GraphRecipe {
|
|
|
181
209
|
docsUrl: string;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
/** Result of a
|
|
212
|
+
/** Result of a graph query. `rows` are objects keyed by column name. */
|
|
185
213
|
export interface GraphResult {
|
|
186
214
|
columns: string[];
|
|
187
215
|
rows: Record<string, unknown>[];
|
|
@@ -237,15 +265,18 @@ export interface GraphFlowResult {
|
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
/**
|
|
240
|
-
* The graph namespace: one typed method per catalog entry (
|
|
241
|
-
* 15 workflow-runner flows), plus a raw `query` escape hatch and
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
268
|
+
* The graph namespace: one typed method per catalog entry (13 direct Cypher reads,
|
|
269
|
+
* `submit`, and 15 workflow-runner flows), plus a raw `query` escape hatch and
|
|
270
|
+
* `recipes()` discovery. Every verb is proxied to the graph with a key if you have one
|
|
271
|
+
* and without if you do not: the graph owns the keyless policy and decides what to
|
|
272
|
+
* answer, so the SDK never refuses a call on your behalf. A key (`{ apiKey }` or
|
|
273
|
+
* `WHISPER_API_KEY`) lifts the keyless rate limit. The control plane (`whisper.agents`:
|
|
274
|
+
* agents, policy, logs, identity) is a separate surface and always requires a key.
|
|
275
|
+
* Every method carries its canonical docs URL as `.docs`.
|
|
245
276
|
*/
|
|
246
277
|
export interface GraphNamespace {
|
|
247
278
|
/**
|
|
248
|
-
* Raw Cypher escape hatch
|
|
279
|
+
* Raw Cypher escape hatch: run arbitrary Cypher with `params` bound
|
|
249
280
|
* server-side as $-parameters. Returns the full `{columns,rows,statistics}`.
|
|
250
281
|
* @see https://www.whisper.security/docs/cypher-api
|
|
251
282
|
*/
|
|
@@ -268,7 +299,7 @@ export interface GraphNamespace {
|
|
|
268
299
|
* letters have in-country DNS_ROOT_INSTANCE anycast nodes, the Global-vs-Local split, the BGP
|
|
269
300
|
* origin ASNs hosting them, and a resilience grade.
|
|
270
301
|
*
|
|
271
|
-
*
|
|
302
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "anycast-dns-root-sovereignty")
|
|
272
303
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
273
304
|
* Inputs: country. Params: instanceType.
|
|
274
305
|
* @see https://www.whisper.security/docs/recipes/compliance
|
|
@@ -281,7 +312,7 @@ export interface GraphNamespace {
|
|
|
281
312
|
* From a starting foothold, finds shared dependencies whose compromise reaches furthest; for
|
|
282
313
|
* any two indicators, traces how they are actually connected.
|
|
283
314
|
*
|
|
284
|
-
*
|
|
315
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "attack-path")
|
|
285
316
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
286
317
|
* Inputs: value, other. Params: level.
|
|
287
318
|
* @see https://www.whisper.security/docs/recipes/attack-path
|
|
@@ -294,7 +325,7 @@ export interface GraphNamespace {
|
|
|
294
325
|
* Maps the full external footprint - subdomains, name/mail servers, registrant, third-party
|
|
295
326
|
* services, connected web - and scores the exposure.
|
|
296
327
|
*
|
|
297
|
-
*
|
|
328
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "attack-surface")
|
|
298
329
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
299
330
|
* Inputs: domain. Params: level.
|
|
300
331
|
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
@@ -307,7 +338,7 @@ export interface GraphNamespace {
|
|
|
307
338
|
* Grades a network on the conflicts/gaps that make route hijacking possible, then traces any
|
|
308
339
|
* conflict to the specific domains and organisations exposed on the affected blocks.
|
|
309
340
|
*
|
|
310
|
-
*
|
|
341
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "bgp-hijack-exposure")
|
|
311
342
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
312
343
|
* Inputs: value.
|
|
313
344
|
* @see https://www.whisper.security/docs/recipes/bgp-routing
|
|
@@ -320,7 +351,7 @@ export interface GraphNamespace {
|
|
|
320
351
|
* Maps dependencies in both directions: everything that breaks if the asset fails (SPOFs) and
|
|
321
352
|
* everything it relies on (its own DNS/mail/hosting/network supply chain).
|
|
322
353
|
*
|
|
323
|
-
*
|
|
354
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "blast-radius")
|
|
324
355
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
325
356
|
* Inputs: indicator. Params: depth.
|
|
326
357
|
* @see https://www.whisper.security/docs/recipes/soc
|
|
@@ -333,7 +364,7 @@ export interface GraphNamespace {
|
|
|
333
364
|
* One-pass takedown package: reputation verdict, owner (WHOIS), abuse-list listings, and
|
|
334
365
|
* surrounding infrastructure, laid out ready to hand to a registrar/host.
|
|
335
366
|
*
|
|
336
|
-
*
|
|
367
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "build-takedown-evidence-package")
|
|
337
368
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
338
369
|
* Inputs: domain.
|
|
339
370
|
* @see https://www.whisper.security/docs/recipes/threat-intel
|
|
@@ -346,7 +377,7 @@ export interface GraphNamespace {
|
|
|
346
377
|
* Maps externally visible AI/agent hosts (API, model, agent) from the outside via heuristic
|
|
347
378
|
* hostname patterns (api./mcp./ai./vector./llm./agent./chat./copilot.). Best-effort leads.
|
|
348
379
|
*
|
|
349
|
-
*
|
|
380
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "discover-ai-agent-infrastructure")
|
|
350
381
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
351
382
|
* Inputs: value.
|
|
352
383
|
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
@@ -360,7 +391,7 @@ export interface GraphNamespace {
|
|
|
360
391
|
* CDN, neighbouring infra), threat-checks each node, never inherits maliciousness from shared
|
|
361
392
|
* infra; labelled posture, no score.
|
|
362
393
|
*
|
|
363
|
-
*
|
|
394
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "indicator")
|
|
364
395
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
365
396
|
* Inputs: indicator.
|
|
366
397
|
* @see https://www.whisper.security/docs/recipes/soc
|
|
@@ -373,7 +404,7 @@ export interface GraphNamespace {
|
|
|
373
404
|
* Fills the picture for one indicator: registrant (WHOIS), hosting + country, mail/name
|
|
374
405
|
* servers, network behind it, reputation read.
|
|
375
406
|
*
|
|
376
|
-
*
|
|
407
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "indicator-enrichment")
|
|
377
408
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
378
409
|
* Inputs: value.
|
|
379
410
|
* @see https://www.whisper.security/docs/recipes/dns-email
|
|
@@ -386,7 +417,7 @@ export interface GraphNamespace {
|
|
|
386
417
|
* Works out the true operator (even behind privacy WHOIS), de-cloaks CDN-fronted sites to real
|
|
387
418
|
* servers, pivots to the rest of that owner's estate across every layer.
|
|
388
419
|
*
|
|
389
|
-
*
|
|
420
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "infrastructure-mapping")
|
|
390
421
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
391
422
|
* Inputs: value. Params: level.
|
|
392
423
|
* @see https://www.whisper.security/docs/recipes/compliance
|
|
@@ -399,7 +430,7 @@ export interface GraphNamespace {
|
|
|
399
430
|
* Grades how concentrated infra is - too much riding on one provider/region/data-centre/cable
|
|
400
431
|
* landing - surfacing SPOFs for resilience and DORA/NIS2 fourth-party risk.
|
|
401
432
|
*
|
|
402
|
-
*
|
|
433
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "map-supply-chain-concentration")
|
|
403
434
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
404
435
|
* Inputs: domain.
|
|
405
436
|
* @see https://www.whisper.security/docs/recipes/compliance
|
|
@@ -412,7 +443,7 @@ export interface GraphNamespace {
|
|
|
412
443
|
* Audits delegation, flags stale/mismatched/lame nameservers, sizes each provider's share,
|
|
413
444
|
* surfaces registry facts - catches delegation weakness before exploit.
|
|
414
445
|
*
|
|
415
|
-
*
|
|
446
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "nameserver-hijack-dns-consistency")
|
|
416
447
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
417
448
|
* Inputs: value.
|
|
418
449
|
* @see https://www.whisper.security/docs/recipes/dns-email
|
|
@@ -425,7 +456,7 @@ export interface GraphNamespace {
|
|
|
425
456
|
* Health card for a network/block: what it announces, peers/transit, single-upstream lean,
|
|
426
457
|
* RPKI protection - the one-look reach & resilience report.
|
|
427
458
|
*
|
|
428
|
-
*
|
|
459
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "route-health")
|
|
429
460
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
430
461
|
* Inputs: target.
|
|
431
462
|
* @see https://www.whisper.security/docs/recipes/bgp-routing
|
|
@@ -439,7 +470,7 @@ export interface GraphNamespace {
|
|
|
439
470
|
* no longer resolves) so you can reclaim/remove before someone else does. CNAME layer is
|
|
440
471
|
* prod-ahead.
|
|
441
472
|
*
|
|
442
|
-
*
|
|
473
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "subdomain-takeover")
|
|
443
474
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
444
475
|
* Inputs: value.
|
|
445
476
|
* @see https://www.whisper.security/docs/recipes/pentest-recon
|
|
@@ -452,7 +483,7 @@ export interface GraphNamespace {
|
|
|
452
483
|
* Finds registered impersonations across misspellings/risky TLDs, separates your own defensive
|
|
453
484
|
* domains from strangers', flags fresh/privacy/abuse-listed ones into a prioritised list.
|
|
454
485
|
*
|
|
455
|
-
*
|
|
486
|
+
* Flow: streams Server-Sent Events from the workflow runner (slug "typosquat")
|
|
456
487
|
* and resolves to the collected { slug, steps, complete, events }.
|
|
457
488
|
* Inputs: domain.
|
|
458
489
|
* @see https://www.whisper.security/docs/recipes/brand-protection
|
|
@@ -465,7 +496,7 @@ export interface GraphNamespace {
|
|
|
465
496
|
* Resolves a host/IP to its canonical vendor, category and operator roles (DNS_OPERATOR / CDN
|
|
466
497
|
* / ORIGIN_AS / MAIL_RECEIVER) with a confidence band.
|
|
467
498
|
*
|
|
468
|
-
*
|
|
499
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
469
500
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
470
501
|
* Cypher: CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band
|
|
471
502
|
* Returns columns: host, vendor_id, canonical_name, category, roles, host_class, band.
|
|
@@ -479,7 +510,7 @@ export interface GraphNamespace {
|
|
|
479
510
|
* Returns a posture label + severity band + sub-labels + coverage + evidence for an indicator;
|
|
480
511
|
* benign-allowlisted vs malicious-evidenced, never a bare score.
|
|
481
512
|
*
|
|
482
|
-
*
|
|
513
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
483
514
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
484
515
|
* Cypher: CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence
|
|
485
516
|
* Returns columns: host, label, band, sub_labels, coverage, evidence.
|
|
@@ -493,7 +524,7 @@ export interface GraphNamespace {
|
|
|
493
524
|
* Enumerates permutations (omission, bitsquatting, ...) of a domain, flags which exist, with a
|
|
494
525
|
* per-variant confidence.
|
|
495
526
|
*
|
|
496
|
-
*
|
|
527
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
497
528
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
498
529
|
* Cypher: CALL whisper.variants($v) YIELD variant, method, exists, confidence
|
|
499
530
|
* Returns columns: variant, method, exists, confidence.
|
|
@@ -508,7 +539,7 @@ export interface GraphNamespace {
|
|
|
508
539
|
* ORIGIN_AS) and confidence, plus siblings and coverage - explains WHY a host maps to a
|
|
509
540
|
* vendor.
|
|
510
541
|
*
|
|
511
|
-
*
|
|
542
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
512
543
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
513
544
|
* Cypher: CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings
|
|
514
545
|
* Returns columns: coverage, host, nearest_known_vendors, no_atlas_match, siblings.
|
|
@@ -523,7 +554,7 @@ export interface GraphNamespace {
|
|
|
523
554
|
* weight, firstSeen/lastSeen). Two live forms: bare CALL explain($v) (full columns) and CALL
|
|
524
555
|
* whisper.explain($v) (restricted YIELD).
|
|
525
556
|
*
|
|
526
|
-
*
|
|
557
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
527
558
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
528
559
|
* Cypher: CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources
|
|
529
560
|
* Returns columns: indicator, score, level, explanation, sources.
|
|
@@ -537,7 +568,7 @@ export interface GraphNamespace {
|
|
|
537
568
|
* PSL-correct eTLD+1: www.foo.co.uk -> foo.co.uk. The right way to group hosts by the thing
|
|
538
569
|
* someone actually registered.
|
|
539
570
|
*
|
|
540
|
-
*
|
|
571
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
541
572
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
542
573
|
* Cypher: CALL whisper.psl.tldPlusOne($v) YIELD apex
|
|
543
574
|
* Returns columns: apex.
|
|
@@ -551,7 +582,7 @@ export interface GraphNamespace {
|
|
|
551
582
|
* For a PSL private-section suffix, returns the submitting org/login and evidence kind +
|
|
552
583
|
* confidence; found=false for ordinary registrable domains.
|
|
553
584
|
*
|
|
554
|
-
*
|
|
585
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
555
586
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
556
587
|
* Cypher: CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence
|
|
557
588
|
* Returns columns: found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence.
|
|
@@ -565,7 +596,7 @@ export interface GraphNamespace {
|
|
|
565
596
|
* Candidate origin IPs behind a CDN with confidence, the methods that found them (e.g.
|
|
566
597
|
* links_to), and the hosting ASN/name - de-cloaks to the real server.
|
|
567
598
|
*
|
|
568
|
-
*
|
|
599
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
569
600
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
570
601
|
* Cypher: CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind
|
|
571
602
|
* Returns columns: ip, confidence, methods, asn, asnName, kind.
|
|
@@ -579,7 +610,7 @@ export interface GraphNamespace {
|
|
|
579
610
|
* Every observed WHOIS snapshot over time: create/update/expiry, registrar, registrant,
|
|
580
611
|
* country, nameservers - the ownership/registration story.
|
|
581
612
|
*
|
|
582
|
-
*
|
|
613
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
583
614
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
584
615
|
* Cypher: CALL whisper.history($v)
|
|
585
616
|
* Returns columns: indicator, type, queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers, cached.
|
|
@@ -593,7 +624,7 @@ export interface GraphNamespace {
|
|
|
593
624
|
* The registration-record projection of the history timeline: create/update/expiry, registrar,
|
|
594
625
|
* registrant, country, nameservers per snapshot.
|
|
595
626
|
*
|
|
596
|
-
*
|
|
627
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
597
628
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
598
629
|
* Cypher: CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers
|
|
599
630
|
* Returns columns: queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers.
|
|
@@ -608,7 +639,7 @@ export interface GraphNamespace {
|
|
|
608
639
|
* STRING as-set name, not an integer ASN. (Membership is sparsely populated in the current
|
|
609
640
|
* graph.)
|
|
610
641
|
*
|
|
611
|
-
*
|
|
642
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
612
643
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
613
644
|
* Cypher: CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir
|
|
614
645
|
* Returns columns: asSetName, memberAsn, sourceRir.
|
|
@@ -622,7 +653,7 @@ export interface GraphNamespace {
|
|
|
622
653
|
* Live Tor exit-node check: found + fingerprint + exit-address count + source + ingest time
|
|
623
654
|
* for an IPv4.
|
|
624
655
|
*
|
|
625
|
-
*
|
|
656
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
626
657
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
627
658
|
* Cypher: CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt
|
|
628
659
|
* Returns columns: indicator, found, fingerprint, exitAddressCount, source, ingestedAt.
|
|
@@ -636,7 +667,7 @@ export interface GraphNamespace {
|
|
|
636
667
|
* The self-describing schema: node/relationship types with counts, descriptions, examples,
|
|
637
668
|
* source/target labels and query best-practices - the map of what the graph holds.
|
|
638
669
|
*
|
|
639
|
-
*
|
|
670
|
+
* Direct read: answers with NO key at all (a rate-limited taste, real
|
|
640
671
|
* answers); pass a key ({ apiKey } or WHISPER_API_KEY) to lift the limit.
|
|
641
672
|
* Cypher: CALL db.schema()
|
|
642
673
|
* Returns columns: type, name, count, description, example, sourceLabels, targetLabels, fastPatterns, slowPatterns, bestPractices.
|
|
@@ -647,10 +678,11 @@ export interface GraphNamespace {
|
|
|
647
678
|
/**
|
|
648
679
|
* Contribute an indicator observation or feedback back into the graph (requires an API key).
|
|
649
680
|
*
|
|
650
|
-
* The write channel: submit an indicator/feedback with an attributable API key
|
|
651
|
-
*
|
|
681
|
+
* The write channel: submit an indicator/feedback with an attributable API key. The graph
|
|
682
|
+
* requires a key carrying the submit:write scope and answers anonymous callers with a clear
|
|
683
|
+
* 400, to preserve K-anonymity.
|
|
652
684
|
*
|
|
653
|
-
*
|
|
685
|
+
* Write: CALL whisper.submit({...}) with the payload fields bound as $-parameters.
|
|
654
686
|
* Fields: kind, identifier_kind, value, observation_id, confidence, first_seen, provenance, query, results, comment, severity, v (kind required).
|
|
655
687
|
* Returns columns: observation_id, kind, accepted, idempotent, promotion_state, k_bucket, advisory, v.
|
|
656
688
|
* @see https://www.whisper.security/docs/cypher-api
|
package/index.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// whisper-id - the Whisper security graph, a routable IPv6 identity, and safe egress for
|
|
4
4
|
// any Node agent. Dependency-free and two-tier (Postel): the graph's direct read verbs and
|
|
5
5
|
// the identity checks run KEYLESS over plain fetch() (graph.assess("8.8.8.8") needs no key
|
|
6
|
-
// at all); the
|
|
7
|
-
// register/egress/ip lean on the
|
|
6
|
+
// at all); raw Cypher and the flows are sent the same way and the graph decides. Only the
|
|
7
|
+
// control plane requires WHISPER_API_KEY, and only register/egress/ip lean on the
|
|
8
|
+
// `whisper` CLI (https://whisper.online).
|
|
8
9
|
import { execFile } from "node:child_process";
|
|
9
10
|
import { promisify } from "node:util";
|
|
10
11
|
|
|
@@ -15,7 +16,11 @@ const HOSTPORT_RE = /127\.0\.0\.1:(\d{2,5})/;
|
|
|
15
16
|
const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
16
17
|
const UA = "whisper-id-node"; // client identifier for the keyless + control HTTP calls
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Error thrown when a `whisper` invocation fails, the CLI is not installed, or the control
|
|
21
|
+
* plane refuses a call. A control-plane refusal carries the server's own `.code` (for example
|
|
22
|
+
* "NOT_FOUND" or "FORBIDDEN"), `.status` and `.retryable` when the server said them.
|
|
23
|
+
*/
|
|
19
24
|
export class WhisperError extends Error {
|
|
20
25
|
constructor(message) {
|
|
21
26
|
super(message);
|
|
@@ -23,6 +28,41 @@ export class WhisperError extends Error {
|
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The control plane understood the call and found nothing of yours to act on. From
|
|
33
|
+
* revoke() it means nothing was revoked: the agent was revoked already, or it is not in your
|
|
34
|
+
* account. That is an answer, not a fault, so never retry it. `.code` is "NOT_FOUND",
|
|
35
|
+
* `.status` is 404, `.retryable` is false, and the message is the server's own.
|
|
36
|
+
*/
|
|
37
|
+
export class NotFoundError extends WhisperError {
|
|
38
|
+
constructor(message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "NotFoundError";
|
|
41
|
+
this.code = "NOT_FOUND";
|
|
42
|
+
this.status = 404;
|
|
43
|
+
this.retryable = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// NOT_FOUND is read off the code, on a 404 or with no error status; never off a bare 404,
|
|
48
|
+
// which is a mistyped control URL or a proxy in the way rather than a statement about the agent.
|
|
49
|
+
function isNotFound(e, status) {
|
|
50
|
+
if (!e || typeof e !== "object") return false;
|
|
51
|
+
if (String(e.code || "").trim().toUpperCase() !== "NOT_FOUND") return false;
|
|
52
|
+
return status === 404 || !status || status < 400;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// An older plane's ok:true revoke row whose status is not_found (directly or under item).
|
|
56
|
+
// Only the first of status/state present is read.
|
|
57
|
+
function revokeRowNotFound(rec) {
|
|
58
|
+
if (!rec || typeof rec !== "object") return false;
|
|
59
|
+
const row = rec.item && typeof rec.item === "object" ? rec.item : rec;
|
|
60
|
+
for (const k of ["status", "state"]) {
|
|
61
|
+
if (typeof row[k] === "string") return row[k].trim().toLowerCase() === "not_found";
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
26
66
|
function bin() {
|
|
27
67
|
return process.env.WHISPER_BIN || "whisper";
|
|
28
68
|
}
|
|
@@ -35,7 +75,7 @@ async function run(args, { timeout = 120000, check = true } = {}) {
|
|
|
35
75
|
if (err && err.code === "ENOENT") {
|
|
36
76
|
throw new WhisperError(
|
|
37
77
|
"the `whisper` CLI was not found on PATH. Install it with " +
|
|
38
|
-
"`curl get.whisper.online | sh` (see https://whisper.online)."
|
|
78
|
+
"`curl -fsSL https://get.whisper.online | sh` (see https://whisper.online)."
|
|
39
79
|
);
|
|
40
80
|
}
|
|
41
81
|
const out = { stdout: err.stdout || "", stderr: err.stderr || "", code: typeof err.code === "number" ? err.code : 1 };
|
|
@@ -308,7 +348,8 @@ function resolveKey(opts) {
|
|
|
308
348
|
|
|
309
349
|
// The one place that talks to the control plane. Builds the injection-proof query, POSTs
|
|
310
350
|
// it with the key in X-API-Key, decodes either envelope shape, and throws WhisperError
|
|
311
|
-
// (carrying the server's
|
|
351
|
+
// (carrying the server's message, plus .code/.status/.retryable/.retryAfter) on ok:false,
|
|
352
|
+
// a NotFoundError when the code is NOT_FOUND. The key
|
|
312
353
|
// is never logged, never placed on argv, never returned.
|
|
313
354
|
async function callControl(op, args, opts = {}) {
|
|
314
355
|
const key = resolveKey(opts);
|
|
@@ -349,8 +390,13 @@ async function callControl(op, args, opts = {}) {
|
|
|
349
390
|
const env = decodeEnvelope(body, res.status);
|
|
350
391
|
if (!env.ok) {
|
|
351
392
|
const e = env.error || {};
|
|
352
|
-
|
|
393
|
+
// The plane's own errors are {code, message, retryable}; RFC 7807 ones use detail/title.
|
|
394
|
+
const msg = e.detail || e.message || e.title || e.type || `control plane returned HTTP ${env.status}`;
|
|
395
|
+
if (isNotFound(e, env.status)) throw new NotFoundError(msg);
|
|
396
|
+
const werr = new WhisperError(msg);
|
|
353
397
|
werr.status = env.status;
|
|
398
|
+
if (e.code) werr.code = String(e.code);
|
|
399
|
+
if (typeof e.retryable === "boolean") werr.retryable = e.retryable;
|
|
354
400
|
if (env.retryAfter != null) werr.retryAfter = env.retryAfter;
|
|
355
401
|
if (e.suggestions) werr.suggestions = e.suggestions;
|
|
356
402
|
throw werr;
|
|
@@ -402,15 +448,30 @@ export async function logs(opts = {}) {
|
|
|
402
448
|
|
|
403
449
|
/**
|
|
404
450
|
* Fully revoke an agent - IRREVERSIBLE (op:revoke; needs admin:dns). Liberal input:
|
|
405
|
-
* `revoke("agent-id")`, `revoke("2a04:2a01:...")`,
|
|
406
|
-
* → `{ status, ... }`.
|
|
451
|
+
* `revoke("agent-id")`, `revoke("2a04:2a01:...")`, `revoke("my-label")`, or
|
|
452
|
+
* `revoke({ agent, apiKey })`. → `{ status, ... }`.
|
|
453
|
+
*
|
|
454
|
+
* Throws NotFoundError when nothing was revoked: no agent of yours matches, because it was
|
|
455
|
+
* revoked already or is not in your account. Do not retry that; list() shows what is left.
|
|
456
|
+
* Both ways the plane says it throw it: the NOT_FOUND error of current servers, and the
|
|
457
|
+
* `status: not_found` row older ones answered with ok:true.
|
|
407
458
|
*/
|
|
408
459
|
export async function revoke(target, maybeOpts) {
|
|
409
460
|
const o = typeof target === "string" ? maybeOpts || {} : target || {};
|
|
410
461
|
const sel = typeof target === "string" ? target : o.agent;
|
|
411
462
|
if (!sel || !String(sel).trim()) throw new WhisperError("revoke() needs an agent id or /128 address");
|
|
412
|
-
const
|
|
463
|
+
const selector = String(sel).trim();
|
|
464
|
+
const recs = await callControl("revoke", { agent: selector }, o);
|
|
413
465
|
const rec = recs.length ? recs[0] : {};
|
|
466
|
+
if (revokeRowNotFound(rec)) {
|
|
467
|
+
const row = rec.item && typeof rec.item === "object" ? rec.item : rec;
|
|
468
|
+
const said = [row.message, row.detail].find((v) => typeof v === "string" && v.trim());
|
|
469
|
+
throw new NotFoundError(
|
|
470
|
+
said ||
|
|
471
|
+
`nothing was revoked: no agent of yours matches ${JSON.stringify(selector)}. ` +
|
|
472
|
+
"If you revoked it before, there is nothing left to revoke; list() shows what is left"
|
|
473
|
+
);
|
|
474
|
+
}
|
|
414
475
|
return { status: rec.status || rec.state || "submitted", ...rec };
|
|
415
476
|
}
|
|
416
477
|
|
|
@@ -463,7 +524,7 @@ export async function agent(sel = {}, maybeOpts) {
|
|
|
463
524
|
// origins, history, historyWhois, asset, lookupTorRelay, dbSchema) run KEYLESS - no key
|
|
464
525
|
// at all, a rate-limited taste (~100/window) with real answers; a key (opts.apiKey or
|
|
465
526
|
// WHISPER_API_KEY) lifts the limit. Raw Cypher (graph.query), the 15 multi-step flows,
|
|
466
|
-
// and the submit write channel
|
|
527
|
+
// and the submit write channel are proxied up the same way. Everything POSTs to the SAME endpoint the
|
|
467
528
|
// control plane uses (https://graph.whisper.online/api/query), reusing resolveKey() so
|
|
468
529
|
// there is one auth path, not two: a present key rides ONLY in the X-API-Key header, an
|
|
469
530
|
// absent one sends NO auth header at all. It does NOT go through the whisper.agents()
|
|
@@ -508,17 +569,12 @@ function normalizeGraph(body) {
|
|
|
508
569
|
// (plus .status/.retryAfter). Two-tier auth (Postel: liberal in what we accept): a keyless
|
|
509
570
|
// method (`keyless: true`) NEVER throws for a missing key - with a key it runs unlimited,
|
|
510
571
|
// without one the X-API-Key header is omitted entirely and the server serves the
|
|
511
|
-
// rate-limited keyless taste.
|
|
572
|
+
// rate-limited keyless taste. No method is refused here; the graph decides what to answer.
|
|
512
573
|
// The key is only ever sent in X-API-Key: never logged, never on argv, never returned.
|
|
513
|
-
async function callGraph(cypher, params, opts = {}
|
|
574
|
+
async function callGraph(cypher, params, opts = {}) {
|
|
575
|
+
// Proxy up with whatever key there is, or none: the graph owns the keyless policy and
|
|
576
|
+
// decides what to answer. The SDK adds no gate the server did not ask for.
|
|
514
577
|
const key = resolveKey(opts) || "";
|
|
515
|
-
if (!key && !keyless) {
|
|
516
|
-
throw new WhisperError(
|
|
517
|
-
`\`graph.${methodName}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
518
|
-
"(The direct read verbs - assess, identify, explain, origins, and friends - run keyless " +
|
|
519
|
-
"with no key at all; raw Cypher, the flows, and submit are keyed. See graph.recipes().)"
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
578
|
const url = graphUrl();
|
|
523
579
|
const timeout = opts.timeout || 60000;
|
|
524
580
|
const ctrl = new AbortController();
|
|
@@ -619,14 +675,14 @@ function graphValue(name, value) {
|
|
|
619
675
|
}
|
|
620
676
|
|
|
621
677
|
/**
|
|
622
|
-
* Run raw Cypher against the Whisper Security graph
|
|
678
|
+
* Run raw Cypher against the Whisper Security graph. The escape hatch under every
|
|
623
679
|
* typed method: `graph.query("MATCH (n) RETURN n LIMIT 1", {}, { apiKey })`. Returns the
|
|
624
680
|
* full `{ columns, rows, statistics }` (rows are objects keyed by column name).
|
|
625
681
|
* @see https://www.whisper.security/docs/cypher-api
|
|
626
682
|
*/
|
|
627
683
|
async function graphQuery(cypher, params, opts = {}) {
|
|
628
684
|
if (!cypher || !String(cypher).trim()) throw new WhisperError("graph.query() needs a Cypher string");
|
|
629
|
-
return callGraph(String(cypher), params || {}, opts
|
|
685
|
+
return callGraph(String(cypher), params || {}, opts);
|
|
630
686
|
}
|
|
631
687
|
|
|
632
688
|
/**
|
|
@@ -650,7 +706,7 @@ function graphRecipes() {
|
|
|
650
706
|
|
|
651
707
|
// ── Flow runner (gallery/run, Server-Sent Events) ───────────────────────────────────
|
|
652
708
|
// A flow entry is a multi-step read workflow orchestrated by the Whisper workflow runner:
|
|
653
|
-
// POST {slug, value, paramValues} to the gallery/run endpoint (
|
|
709
|
+
// POST {slug, value, paramValues} to the gallery/run endpoint (X-API-Key when set) and consume
|
|
654
710
|
// the SSE stream. Each `step` event carries a step's bound Cypher + rows; the terminal
|
|
655
711
|
// `complete` event carries the aggregate context. callFlow() collects the whole stream and
|
|
656
712
|
// resolves to { slug, steps, complete, events } - one call, the full flow.
|
|
@@ -732,19 +788,15 @@ function parseSseRecord(record) {
|
|
|
732
788
|
return { event, data };
|
|
733
789
|
}
|
|
734
790
|
|
|
735
|
-
// The one place that talks to the workflow runner.
|
|
736
|
-
// POSTs {slug, value, paramValues},
|
|
791
|
+
// The one place that talks to the workflow runner. Sends the key when there is one (shared
|
|
792
|
+
// resolveKey) and goes without when there is not, POSTs {slug, value, paramValues},
|
|
793
|
+
// consumes the SSE stream, and resolves to the collected
|
|
737
794
|
// { slug, steps, complete, events }. Flows are multi-step, so the default timeout is 5
|
|
738
795
|
// minutes (override with opts.timeout). On an HTTP error, an `error` event, or a stream
|
|
739
796
|
// that ends with no result at all, throws a WhisperError with the server's clear detail.
|
|
740
797
|
async function callFlow(m, input, opts = {}) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
throw new WhisperError(
|
|
744
|
-
`\`graph.${m.name}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
745
|
-
"(The graph's direct read verbs run keyless; flows run keyed on the workflow runner.)"
|
|
746
|
-
);
|
|
747
|
-
}
|
|
798
|
+
// Flows proxy up like every other graph verb; the runner decides on the key.
|
|
799
|
+
const key = resolveKey(opts) || "";
|
|
748
800
|
const body = flowWireBody(m, input);
|
|
749
801
|
const url = flowRunUrl();
|
|
750
802
|
const timeout = opts.timeout || 300000;
|
|
@@ -753,14 +805,17 @@ async function callFlow(m, input, opts = {}) {
|
|
|
753
805
|
try {
|
|
754
806
|
let res;
|
|
755
807
|
try {
|
|
808
|
+
const headers = {
|
|
809
|
+
"Content-Type": "application/json",
|
|
810
|
+
Accept: "text/event-stream",
|
|
811
|
+
"User-Agent": UA,
|
|
812
|
+
};
|
|
813
|
+
// Present => keyed. ABSENT => keyless: an empty credential is not the same as no
|
|
814
|
+
// credential, and a server handed "" refuses instead of applying keyless policy.
|
|
815
|
+
if (key) headers["X-API-Key"] = key;
|
|
756
816
|
res = await fetch(url, {
|
|
757
817
|
method: "POST",
|
|
758
|
-
headers
|
|
759
|
-
"Content-Type": "application/json",
|
|
760
|
-
Accept: "text/event-stream",
|
|
761
|
-
"X-API-Key": key,
|
|
762
|
-
"User-Agent": UA,
|
|
763
|
-
},
|
|
818
|
+
headers,
|
|
764
819
|
body: JSON.stringify(body),
|
|
765
820
|
signal: ctrl.signal,
|
|
766
821
|
});
|
|
@@ -868,7 +923,7 @@ function makeMapCall(m) {
|
|
|
868
923
|
}
|
|
869
924
|
const keys = Object.keys(bound).sort();
|
|
870
925
|
const cypher = `CALL ${m.proc}({` + keys.map((k) => `${k}:$${k}`).join(", ") + "})";
|
|
871
|
-
return callGraph(cypher, bound, opts
|
|
926
|
+
return callGraph(cypher, bound, opts);
|
|
872
927
|
};
|
|
873
928
|
}
|
|
874
929
|
|
|
@@ -886,9 +941,9 @@ for (const m of GRAPH_METHODS) {
|
|
|
886
941
|
} else if (m.fields) {
|
|
887
942
|
graph[m.name] = makeMapCall(m);
|
|
888
943
|
} else if (m.input) {
|
|
889
|
-
graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts
|
|
944
|
+
graph[m.name] = async (value, opts = {}) => callGraph(m.cypher, { [m.input]: graphValue(m.name, value) }, opts);
|
|
890
945
|
} else {
|
|
891
|
-
graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts
|
|
946
|
+
graph[m.name] = async (opts = {}) => callGraph(m.cypher, {}, opts);
|
|
892
947
|
}
|
|
893
948
|
graph[m.name].docs = m.docs;
|
|
894
949
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whisper-id",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Give any Node agent the Whisper security graph (keyless reads
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Give any Node agent the Whisper security graph (keyless reads, no client-side gate), a real routable Whisper IPv6 identity, safe egress, and the full control plane.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"index.js",
|
|
15
15
|
"index.d.ts",
|
|
16
16
|
"README.md",
|
|
17
|
+
"SECURITY.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|