whisper-id 0.2.0 → 0.4.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 +110 -21
- package/index.d.ts +199 -1
- package/index.js +469 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
# whisper-id
|
|
2
2
|
|
|
3
|
-
A real, routable **IPv6 identity** and **safe egress** for any Node agent
|
|
3
|
+
A real, routable **IPv6 identity** and **safe egress** for any Node agent.
|
|
4
|
+
|
|
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`.
|
|
4
10
|
|
|
5
11
|
```sh
|
|
6
12
|
npm i whisper-id
|
|
7
13
|
```
|
|
8
14
|
|
|
9
15
|
```js
|
|
10
|
-
import {
|
|
16
|
+
import { verify } from "whisper-id";
|
|
17
|
+
await verify("2a04:2a01:..."); // keyless: true if it's a real Whisper agent
|
|
18
|
+
```
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
```js
|
|
21
|
+
import { identity, withEgress } from "whisper-id"; // with a key (WHISPER_API_KEY)
|
|
22
|
+
const me = await identity({ label: "my-bot" }); // allocate a routable Whisper /128
|
|
13
23
|
await withEgress(async (e) => {
|
|
14
24
|
// proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) now points at your local Whisper proxy
|
|
15
25
|
// → curl, axios, got, and other env-aware clients leave from your /128.
|
|
@@ -19,27 +29,101 @@ await withEgress(async (e) => {
|
|
|
19
29
|
|
|
20
30
|
## API
|
|
21
31
|
|
|
22
|
-
|
|
23
|
-
|------|------|
|
|
24
|
-
| `register(name, { newKey })` | Create a named agent — a routable `/128`. `newKey: true` mints a new agent **and** its own API key. → `Agent { address, id, name }` |
|
|
25
|
-
| `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. |
|
|
26
|
-
| `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
|
|
27
|
-
| `verify(address)` | Keyless — is `address` a real Whisper agent? (DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
|
|
28
|
-
| `verifyDetails(address)` | Keyless — the full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, …) or `null` |
|
|
29
|
-
| `rdap(address)` | Keyless — the public RDAP record for a `/128`, or `null` |
|
|
30
|
-
| `egressIp()` | Keyless — the IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
|
|
31
|
-
| `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
|
|
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 |
|
|
37
|
+
|------|------|
|
|
38
|
+
| `verify(address)` | Is `address` a real Whisper agent? (server-side DANE + DNSSEC + reverse-DNS + JWS) → `boolean` |
|
|
39
|
+
| `verifyDetails(address)` | The full verdict (`is_whisper_agent`, `fqdn`, `operator`, `dane_ok`, `jws_ok`, and more) or `null` |
|
|
40
|
+
| `rdap(address)` | The public RDAP record for a `/128`, or `null` |
|
|
41
|
+
| `egressIp()` | The IP this process leaves from (a `/128` when routed, else the platform's) → `string` |
|
|
36
42
|
|
|
37
43
|
```js
|
|
38
44
|
import { verify, rdap } from "whisper-id";
|
|
39
45
|
if (await verify(addr)) console.log((await rdap(addr)).name);
|
|
40
46
|
```
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
### Control plane: with a key (pure HTTP, no CLI)
|
|
49
|
+
|
|
50
|
+
Pure `fetch` to the control endpoint, with no `whisper` CLI and no child process. The key comes from
|
|
51
|
+
`opts.apiKey` or `WHISPER_API_KEY` and is sent only in the `X-API-Key` header (never on argv,
|
|
52
|
+
never logged). Each also takes `{ apiKey, timeout }`. On failure they throw a `WhisperError`
|
|
53
|
+
carrying the server's `detail` (plus `.status` / `.retryAfter`).
|
|
54
|
+
|
|
55
|
+
| Call | Does |
|
|
56
|
+
|------|------|
|
|
57
|
+
| `list({ kind })` | List your fleet. `kind`: `"agents"` (default) \| `"identities"` \| `"records"`. → items `{ label, fqdn, address, agent, created, state }` |
|
|
58
|
+
| `identity({ label, contact_email })` | Allocate your own `/128`. Release with `{ release: true, address }`. → `{ agent, address, fqdn, ptr, state }` |
|
|
59
|
+
| `agent(idOrAddress)` | One agent's detail + live counters. → the record, or `null` |
|
|
60
|
+
| `policy({ default, allow, block })` | Read (no fields) or set your per-tenant DNS policy. → `{ key, value }` rows |
|
|
61
|
+
| `logs({ agent, kind, from, to, limit })` | Recent DNS/conn/alloc activity from warm storage. → event records |
|
|
62
|
+
| `revoke(idOrAddress)` | Fully revoke an agent, **irreversible** (needs `admin:dns`). → `{ status }` |
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { list, policy, logs } from "whisper-id"; // WHISPER_API_KEY in the env
|
|
66
|
+
|
|
67
|
+
await policy({ default: "deny", allow: ["api.openai.com", "*.githubusercontent.com"] });
|
|
68
|
+
for (const a of await list()) console.log(a.address, a.label);
|
|
69
|
+
const recent = await logs({ kind: "dns", limit: 100 });
|
|
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) run via the Whisper workflow runner, not a single Cypher
|
|
113
|
+
call, so they are not wired into this SDK yet: calling one rejects with a clear `WhisperError`
|
|
114
|
+
(carrying `.runVia`) telling you which workflow to run. Use `graph.query` for raw Cypher meanwhile.
|
|
115
|
+
|
|
116
|
+
### Egress + CLI-backed
|
|
117
|
+
|
|
118
|
+
| Call | Does |
|
|
119
|
+
|------|------|
|
|
120
|
+
| `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 }` |
|
|
121
|
+
| `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. |
|
|
122
|
+
| `withEgress([opts], fn)` | Run `fn(egress)` with egress up, restoring the environment afterwards. |
|
|
123
|
+
| `ip()` | Your current egress IP via the CLI, proving it's your `/128`. → `string` |
|
|
124
|
+
|
|
125
|
+
`register`, `egress`, `withEgress`, and `ip` need the `whisper` CLI on the host (egress brings up
|
|
126
|
+
the local proxy). Everything under **Keyless** and **Control plane** is pure HTTP and needs no CLI.
|
|
43
127
|
|
|
44
128
|
### Using it with `fetch`
|
|
45
129
|
|
|
@@ -59,18 +143,23 @@ transparently inside `withEgress`.
|
|
|
59
143
|
|
|
60
144
|
## Requirements
|
|
61
145
|
|
|
62
|
-
|
|
146
|
+
Zero dependencies. The **keyless** checks and the **control plane** are pure HTTP (Node ≥ 18's
|
|
147
|
+
global `fetch`), with no CLI and nothing to install. For the control plane, set `WHISPER_API_KEY` (or
|
|
148
|
+
pass `{ apiKey }`).
|
|
149
|
+
|
|
150
|
+
Only **egress** and the CLI-backed calls (`register`, `egress`, `withEgress`, `ip`) need the
|
|
151
|
+
`whisper` CLI on your `PATH`:
|
|
63
152
|
|
|
64
153
|
```sh
|
|
65
154
|
curl get.whisper.online | sh
|
|
66
155
|
```
|
|
67
156
|
|
|
68
|
-
|
|
69
|
-
|
|
157
|
+
(`$WHISPER_BIN` overrides the CLI path; `$WHISPER_CONTROL_URL` / `$WHISPER_GRAPH_URL` /
|
|
158
|
+
`$WHISPER_RDAP_URL` override the endpoints.)
|
|
70
159
|
|
|
71
160
|
## Links
|
|
72
161
|
|
|
73
|
-
- Site
|
|
74
|
-
- CLI
|
|
162
|
+
- Site: https://whisper.online
|
|
163
|
+
- CLI: https://github.com/whisper-sec/whisper-cli
|
|
75
164
|
|
|
76
165
|
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;
|
|
@@ -50,3 +50,201 @@ export function egressIp(opts?: { timeout?: number }): Promise<string>;
|
|
|
50
50
|
/** Your current egress IP via the CLI (proves your /128). */
|
|
51
51
|
export function ip(opts?: { timeout?: number }): Promise<string>;
|
|
52
52
|
export function firstAddr(obj: unknown): string | null;
|
|
53
|
+
|
|
54
|
+
// ── Control plane (pure HTTP, keyed - no CLI) ───────────────────────────────────────
|
|
55
|
+
// The governance half of the two-tier surface: pure `fetch` to the control endpoint, so
|
|
56
|
+
// it runs anywhere (serverless, edge, a plain server). The key is read from `opts.apiKey`
|
|
57
|
+
// or `WHISPER_API_KEY` and sent only in the `X-API-Key` header. On failure these throw a
|
|
58
|
+
// `WhisperError` carrying the server's `detail` (and `.status` / `.retryAfter`).
|
|
59
|
+
|
|
60
|
+
/** Common options for every keyed control call. */
|
|
61
|
+
export interface ControlOptions {
|
|
62
|
+
/** The Whisper owner key (`whisper_live_...`). Falls back to `process.env.WHISPER_API_KEY`. */
|
|
63
|
+
apiKey?: string;
|
|
64
|
+
/** Per-call timeout in ms (default 60000). */
|
|
65
|
+
timeout?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One agent as returned by `list` / `agent` (fields present depend on the op). */
|
|
69
|
+
export interface AgentItem {
|
|
70
|
+
label?: string;
|
|
71
|
+
fqdn?: string;
|
|
72
|
+
address?: string;
|
|
73
|
+
agent?: string;
|
|
74
|
+
created?: string | number;
|
|
75
|
+
state?: string;
|
|
76
|
+
[k: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A `{ key, value }` row of the per-tenant DNS policy. */
|
|
80
|
+
export interface PolicyEntry {
|
|
81
|
+
key: string;
|
|
82
|
+
value: unknown;
|
|
83
|
+
[k: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One activity event from `logs` (columns depend on `kind`). */
|
|
87
|
+
export interface LogEvent {
|
|
88
|
+
ts?: string | number;
|
|
89
|
+
kind?: string;
|
|
90
|
+
qname?: string;
|
|
91
|
+
qtype?: string;
|
|
92
|
+
rcode?: string;
|
|
93
|
+
decision?: string;
|
|
94
|
+
source?: string;
|
|
95
|
+
answer?: string;
|
|
96
|
+
agent?: string;
|
|
97
|
+
peer?: string;
|
|
98
|
+
[k: string]: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ListOptions extends ControlOptions {
|
|
102
|
+
/** What to list: "agents" (default) | "identities" | "records". */
|
|
103
|
+
kind?: "agents" | "identities" | "records" | string;
|
|
104
|
+
}
|
|
105
|
+
export interface PolicyOptions extends ControlOptions {
|
|
106
|
+
/** Default action when nothing matches: "allow" | "deny". Omit all fields to READ. */
|
|
107
|
+
default?: "allow" | "deny" | string;
|
|
108
|
+
/** Names to allow. */
|
|
109
|
+
allow?: string[];
|
|
110
|
+
/** Names to block. */
|
|
111
|
+
block?: string[];
|
|
112
|
+
}
|
|
113
|
+
export interface LogsOptions extends ControlOptions {
|
|
114
|
+
agent?: string;
|
|
115
|
+
kind?: "dns" | "conn" | "alloc" | string;
|
|
116
|
+
from?: string | number;
|
|
117
|
+
to?: string | number;
|
|
118
|
+
limit?: number;
|
|
119
|
+
}
|
|
120
|
+
export interface RevokeOptions extends ControlOptions {
|
|
121
|
+
agent?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface IdentityOptions extends ControlOptions {
|
|
124
|
+
/** The agent's human name (required to allocate). */
|
|
125
|
+
label?: string;
|
|
126
|
+
/** Opt-in public contact email (surfaced in RDAP). */
|
|
127
|
+
contact_email?: string;
|
|
128
|
+
/** Release form: set true and pass `address` to release an existing /128. */
|
|
129
|
+
release?: boolean;
|
|
130
|
+
/** The /128 to release (with `release:true`). */
|
|
131
|
+
address?: string;
|
|
132
|
+
}
|
|
133
|
+
export interface AgentSelectOptions extends ControlOptions {
|
|
134
|
+
agent?: string;
|
|
135
|
+
address?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** List your fleet (keyed). → items (`{label, fqdn, address, agent, created, state}`). */
|
|
139
|
+
export function list(kind: string, opts?: ControlOptions): Promise<AgentItem[]>;
|
|
140
|
+
export function list(opts?: ListOptions): Promise<AgentItem[]>;
|
|
141
|
+
/** Read (no policy fields) or set your per-tenant DNS resolver policy. → `{key,value}` rows. */
|
|
142
|
+
export function policy(opts?: PolicyOptions): Promise<PolicyEntry[]>;
|
|
143
|
+
/** Query recent DNS/conn/alloc activity from warm storage. → event records. */
|
|
144
|
+
export function logs(opts?: LogsOptions): Promise<LogEvent[]>;
|
|
145
|
+
/** Fully revoke an agent - IRREVERSIBLE (admin:dns). → `{ status, ... }`. */
|
|
146
|
+
export function revoke(agent: string, opts?: ControlOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
147
|
+
export function revoke(opts: RevokeOptions): Promise<{ status: string; [k: string]: unknown }>;
|
|
148
|
+
/** Allocate your own /128 (or release with `{release:true,address}`). → `{agent,address,fqdn,ptr,state}`. */
|
|
149
|
+
export function identity(opts?: IdentityOptions): Promise<AgentItem | null>;
|
|
150
|
+
/** One agent's detail + live counters. Accepts an id, a /128, or `{agent}`/`{address}`. */
|
|
151
|
+
export function agent(sel: string, opts?: ControlOptions): Promise<AgentItem | null>;
|
|
152
|
+
export function agent(opts?: AgentSelectOptions): Promise<AgentItem | null>;
|
|
153
|
+
|
|
154
|
+
// ── Graph (keyed Cypher 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). It POSTs raw Cypher to
|
|
158
|
+
// the same endpoint the control plane uses, with the key only in `X-API-Key`. `direct`
|
|
159
|
+
// methods run their Cypher and resolve to a `GraphResult`; the 15 `flow` methods are
|
|
160
|
+
// workflow-runner reads not wired here, so calling one rejects with a clear `WhisperError`.
|
|
161
|
+
|
|
162
|
+
/** Per-call options for the keyed graph namespace (same shape as the control plane). */
|
|
163
|
+
export interface GraphOptions extends ControlOptions {}
|
|
164
|
+
|
|
165
|
+
/** Result of a keyed graph query. `rows` are objects keyed by column name. */
|
|
166
|
+
export interface GraphResult {
|
|
167
|
+
columns: string[];
|
|
168
|
+
rows: Record<string, unknown>[];
|
|
169
|
+
statistics?: { rowCount?: number; executionTimeMs?: number; [k: string]: unknown } | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A `graph.submit` payload: an indicator observation or a piece of feedback. */
|
|
173
|
+
export interface GraphSubmission {
|
|
174
|
+
kind: "indicator" | "feedback" | string;
|
|
175
|
+
[k: string]: unknown;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The keyed graph namespace: typed Cypher methods over the Whisper Security graph, plus a
|
|
180
|
+
* raw `query` escape hatch. Every method needs a key (`{ apiKey }` or `WHISPER_API_KEY`).
|
|
181
|
+
*/
|
|
182
|
+
export interface GraphNamespace {
|
|
183
|
+
/** Raw Cypher escape hatch. Returns the full `{columns,rows,statistics}`. */
|
|
184
|
+
query(cypher: string, params?: Record<string, unknown>, opts?: GraphOptions): Promise<GraphResult>;
|
|
185
|
+
|
|
186
|
+
/** Name the vendor and operator role behind a host or IP in one call. */
|
|
187
|
+
identify(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
188
|
+
/** Labelled threat posture for a host or IP: malicious, benign, or unknown. */
|
|
189
|
+
assess(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
190
|
+
/** Generate look-alike domain variants of a brand and see which are registered. */
|
|
191
|
+
variants(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
192
|
+
/** Walk the graph to the nearest known vendors behind a host, with channel and confidence. */
|
|
193
|
+
walk(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
194
|
+
/** Score an indicator against the threat feeds and explain exactly why. */
|
|
195
|
+
explain(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
196
|
+
/** Reduce any hostname to its registrable apex (eTLD+1) via the Public Suffix List. */
|
|
197
|
+
pslTldplusone(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
198
|
+
/** Check whether a domain is a PSL private-section suffix and who submitted it. */
|
|
199
|
+
pslAffiliation(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
200
|
+
/** Find the real origin IPs behind a CDN-fronted domain, ranked by confidence. */
|
|
201
|
+
origins(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
202
|
+
/** Full historical WHOIS timeline for a domain. */
|
|
203
|
+
history(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
204
|
+
/** WHOIS-only historical timeline for a domain. */
|
|
205
|
+
historyWhois(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
206
|
+
/** List the member ASNs of an AS-SET macro. */
|
|
207
|
+
asset(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
208
|
+
/** Check whether an IP is a known Tor exit relay. */
|
|
209
|
+
lookupTorRelay(value: string, opts?: GraphOptions): Promise<GraphResult>;
|
|
210
|
+
/** List every node and relationship type in the graph with counts and examples. No input. */
|
|
211
|
+
dbSchema(opts?: GraphOptions): Promise<GraphResult>;
|
|
212
|
+
/** Contribute an indicator observation or feedback back into the graph (keyed WRITE). */
|
|
213
|
+
submit(payload: GraphSubmission, opts?: GraphOptions): Promise<GraphResult>;
|
|
214
|
+
|
|
215
|
+
// The 15 `flow` workflow-runner methods. Not wired into this SDK: each rejects with a clear
|
|
216
|
+
// WhisperError (carrying `.runVia`). Run them via the console/agent workflow runner.
|
|
217
|
+
/** Workflow (run_workflow): assess a country's DNS-root sovereignty. Not wired here. */
|
|
218
|
+
anycastDnsRootSovereignty(opts?: GraphOptions): Promise<never>;
|
|
219
|
+
/** Workflow (run_workflow): find the choke points an attacker would target. Not wired here. */
|
|
220
|
+
attackPath(opts?: GraphOptions): Promise<never>;
|
|
221
|
+
/** Workflow (run_workflow): map a domain's externally exposed attack surface. Not wired here. */
|
|
222
|
+
attackSurface(opts?: GraphOptions): Promise<never>;
|
|
223
|
+
/** Workflow (run_workflow): grade a network's routing security. Not wired here. */
|
|
224
|
+
bgpHijackExposure(opts?: GraphOptions): Promise<never>;
|
|
225
|
+
/** Workflow (run_workflow): what breaks if one asset fails, and its dependencies. Not wired here. */
|
|
226
|
+
blastRadius(opts?: GraphOptions): Promise<never>;
|
|
227
|
+
/** Workflow (run_workflow): assemble a takedown evidence dossier. Not wired here. */
|
|
228
|
+
buildTakedownEvidencePackage(opts?: GraphOptions): Promise<never>;
|
|
229
|
+
/** Workflow (run_workflow): map an org's externally visible AI/agent endpoints. Not wired here. */
|
|
230
|
+
discoverAiAgentInfrastructure(opts?: GraphOptions): Promise<never>;
|
|
231
|
+
/** Workflow (run_workflow): investigate one indicator in depth. Not wired here. */
|
|
232
|
+
indicator(opts?: GraphOptions): Promise<never>;
|
|
233
|
+
/** Workflow (run_workflow): turn one domain/IP into a full context card. Not wired here. */
|
|
234
|
+
indicatorEnrichment(opts?: GraphOptions): Promise<never>;
|
|
235
|
+
/** Workflow (run_workflow): trace an indicator to its true owner and estate. Not wired here. */
|
|
236
|
+
infrastructureMapping(opts?: GraphOptions): Promise<never>;
|
|
237
|
+
/** Workflow (run_workflow): grade an org for supply-chain concentration. Not wired here. */
|
|
238
|
+
mapSupplyChainConcentration(opts?: GraphOptions): Promise<never>;
|
|
239
|
+
/** Workflow (run_workflow): check name servers for DNS-hijack misconfigurations. Not wired here. */
|
|
240
|
+
nameserverHijackDnsConsistency(opts?: GraphOptions): Promise<never>;
|
|
241
|
+
/** Workflow (run_workflow): profile a network/block into a routing health card. Not wired here. */
|
|
242
|
+
routeHealth(opts?: GraphOptions): Promise<never>;
|
|
243
|
+
/** Workflow (run_workflow): find subdomains that point at claimable services. Not wired here. */
|
|
244
|
+
subdomainTakeover(opts?: GraphOptions): Promise<never>;
|
|
245
|
+
/** Workflow (run_workflow): find registered look-alikes of your brand. Not wired here. */
|
|
246
|
+
typosquat(opts?: GraphOptions): Promise<never>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The keyed graph namespace. `import { graph } from "whisper-id"`. */
|
|
250
|
+
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";
|
|
@@ -11,6 +11,7 @@ const pexec = promisify(execFile);
|
|
|
11
11
|
const ADDR_RE = /2a04:2a01:[0-9a-fA-F:]{2,}/; // the public Whisper agent prefix (AS219419)
|
|
12
12
|
const HOSTPORT_RE = /127\.0\.0\.1:(\d{2,5})/;
|
|
13
13
|
const PROXY_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
14
|
+
const UA = "whisper-id-node"; // client identifier for the keyless + control HTTP calls
|
|
14
15
|
|
|
15
16
|
/** Error thrown when a `whisper` invocation fails, or the CLI is not installed. */
|
|
16
17
|
export class WhisperError extends Error {
|
|
@@ -71,7 +72,7 @@ function getKey(obj, ...keys) {
|
|
|
71
72
|
return null;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
/** Create a named agent identity
|
|
75
|
+
/** Create a named agent identity - a routable Whisper IPv6 /128. */
|
|
75
76
|
export async function register(name, { newKey = false, timeout = 120000 } = {}) {
|
|
76
77
|
if (!name || !name.trim()) throw new WhisperError("register() needs a non-empty agent name");
|
|
77
78
|
const args = ["create", "--name", name];
|
|
@@ -128,7 +129,7 @@ export async function withEgress(optsOrFn, maybeFn) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// Keyless public endpoint base (the same surface the CLI uses, server-side). No key, no `whisper`
|
|
131
|
-
// CLI
|
|
132
|
+
// CLI - these work in any runtime that can fetch() (serverless, edge, browser). Override via env.
|
|
132
133
|
function rdapBase() {
|
|
133
134
|
return (process.env.WHISPER_RDAP_URL || "https://rdap.whisper.online").replace(/\/+$/, "");
|
|
134
135
|
}
|
|
@@ -138,7 +139,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
|
|
|
138
139
|
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
139
140
|
let res;
|
|
140
141
|
try {
|
|
141
|
-
res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json, */*", "User-Agent":
|
|
142
|
+
res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json, */*", "User-Agent": UA }, signal: ctrl.signal });
|
|
142
143
|
} catch (err) {
|
|
143
144
|
throw new WhisperError(`could not reach ${url}: ${err && err.message ? err.message : err}`);
|
|
144
145
|
} finally {
|
|
@@ -154,7 +155,7 @@ async function httpGetJson(url, { timeout = 60000 } = {}) {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
/**
|
|
157
|
-
* 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
|
|
158
159
|
* the public /verify-identity endpoint (server-side DANE+DNSSEC+reverse-DNS+JWS chain). Works in
|
|
159
160
|
* any runtime. 200 ⇒ agent, 404 ⇒ not. Throws only if the endpoint is unreachable.
|
|
160
161
|
*/
|
|
@@ -162,7 +163,7 @@ export async function verify(address, { timeout = 60000 } = {}) {
|
|
|
162
163
|
return (await verifyDetails(address, { timeout })) !== null;
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
/** 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. */
|
|
166
167
|
export async function verifyDetails(address, { timeout = 60000 } = {}) {
|
|
167
168
|
if (!address || !address.trim()) throw new WhisperError("verify() needs an address");
|
|
168
169
|
const url = `${rdapBase()}/verify-identity?ip=${encodeURIComponent(address.trim())}`;
|
|
@@ -171,10 +172,10 @@ export async function verifyDetails(address, { timeout = 60000 } = {}) {
|
|
|
171
172
|
return body;
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
/** 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. */
|
|
175
176
|
export async function rdap(address, { timeout = 60000 } = {}) {
|
|
176
177
|
if (!address || !address.trim()) throw new WhisperError("rdap() needs an address");
|
|
177
|
-
const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment
|
|
178
|
+
const url = `${rdapBase()}/ip/${encodeURI(address.trim())}`; // path segment - keep the literal colons
|
|
178
179
|
const { status, body } = await httpGetJson(url, { timeout });
|
|
179
180
|
return status === 200 ? body : null;
|
|
180
181
|
}
|
|
@@ -191,3 +192,463 @@ export async function ip({ timeout = 60000 } = {}) {
|
|
|
191
192
|
const env = await runJson(["ip"], { timeout });
|
|
192
193
|
return getKey(env, "ip", "agent") || firstAddr(env) || "";
|
|
193
194
|
}
|
|
195
|
+
|
|
196
|
+
// ── Control plane (pure HTTP - no CLI) ──────────────────────────────────────────────
|
|
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 - `whisper.agents({op,args})`
|
|
199
|
+
// - POSTed straight to the control endpoint. No `whisper` CLI, no child process, no key on
|
|
200
|
+
// argv: works in any runtime with fetch() (serverless, edge, a plain server). The key is
|
|
201
|
+
// read from opts.apiKey or $WHISPER_API_KEY and sent ONLY in the X-API-Key header.
|
|
202
|
+
|
|
203
|
+
function controlUrl() {
|
|
204
|
+
return process.env.WHISPER_CONTROL_URL || "https://graph.whisper.security/api/query";
|
|
205
|
+
}
|
|
206
|
+
|
|
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
|
+
// backslashes first). Mirrors the reference client's cypher builder byte-for-byte.
|
|
210
|
+
function escapeCypher(s) {
|
|
211
|
+
return String(s).replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
212
|
+
}
|
|
213
|
+
function quoteCypher(s) {
|
|
214
|
+
return "'" + escapeCypher(s) + "'";
|
|
215
|
+
}
|
|
216
|
+
function lit(v) {
|
|
217
|
+
if (v === null || v === undefined) return "null";
|
|
218
|
+
if (typeof v === "string") return quoteCypher(v);
|
|
219
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
220
|
+
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "null";
|
|
221
|
+
if (Array.isArray(v)) return "[" + v.map(lit).join(",") + "]";
|
|
222
|
+
if (typeof v === "object") return cypherMap(v);
|
|
223
|
+
return quoteCypher(String(v));
|
|
224
|
+
}
|
|
225
|
+
// A Cypher map literal with keys in SORTED order → the query is byte-stable. undefined
|
|
226
|
+
// values are dropped so callers can pass a sparse args object.
|
|
227
|
+
function cypherMap(m) {
|
|
228
|
+
const keys = Object.keys(m).filter((k) => m[k] !== undefined).sort();
|
|
229
|
+
if (keys.length === 0) return "{}";
|
|
230
|
+
return "{" + keys.map((k) => k + ":" + lit(m[k])).join(",") + "}";
|
|
231
|
+
}
|
|
232
|
+
function buildAgentsQuery(op, args) {
|
|
233
|
+
const inner = args && Object.keys(args).some((k) => args[k] !== undefined) ? cypherMap(args) : "{}";
|
|
234
|
+
return "CALL whisper.agents({op:" + quoteCypher(op) + ", args:" + inner + "})";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Liberal-accept (RFC 761): decode BOTH documented envelope shapes into one normalised
|
|
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
|
+
// bare RFC-7807 problem object. Never throws on shape; a fault becomes error, not a crash.
|
|
241
|
+
function decodeEnvelope(body, httpStatus) {
|
|
242
|
+
const out = { ok: false, status: httpStatus, result: null, error: null, retryAfter: null };
|
|
243
|
+
if (!body || typeof body !== "object") {
|
|
244
|
+
out.error = { status: httpStatus, detail: httpStatus >= 400 ? "control plane returned a non-JSON error reply" : "control plane returned an unexpected reply" };
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
const applyRow = (row) => {
|
|
248
|
+
out.ok = !!row.ok;
|
|
249
|
+
out.result = row.result || null;
|
|
250
|
+
out.error = row.error || null;
|
|
251
|
+
if (row.status) out.status = row.status;
|
|
252
|
+
if (row.retry_after != null) out.retryAfter = row.retry_after;
|
|
253
|
+
if (!out.ok && !out.error) out.error = { status: out.status, detail: "control plane reported failure" };
|
|
254
|
+
if (out.error && !out.error.status) out.error.status = out.status;
|
|
255
|
+
return out;
|
|
256
|
+
};
|
|
257
|
+
// Shape B - an explicit ok flag at the top level.
|
|
258
|
+
if (typeof body.ok === "boolean") return applyRow(body);
|
|
259
|
+
// Shape A - the procedure-row table: rows[0] carries {op,ok,status,result,error,...}. The
|
|
260
|
+
// row may be an object already, or a positional array aligned to top-level columns.
|
|
261
|
+
if (Array.isArray(body.rows) && body.rows.length > 0) {
|
|
262
|
+
let row0 = body.rows[0];
|
|
263
|
+
if (Array.isArray(row0) && Array.isArray(body.columns)) {
|
|
264
|
+
const obj = {};
|
|
265
|
+
body.columns.forEach((c, i) => { obj[c] = row0[i]; });
|
|
266
|
+
row0 = obj;
|
|
267
|
+
}
|
|
268
|
+
if (row0 && typeof row0 === "object" && !Array.isArray(row0)) {
|
|
269
|
+
if (typeof row0.ok === "boolean") return applyRow(row0);
|
|
270
|
+
if (row0.result) { out.ok = true; out.result = row0.result; return out; }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// A bare RFC-7807 problem object (no ok, no rows).
|
|
274
|
+
if (body.error || body.detail || body.title || body.type) {
|
|
275
|
+
out.error = body.error || { status: body.status || httpStatus, detail: body.detail, title: body.title, type: body.type };
|
|
276
|
+
if (!out.error.status) out.error.status = httpStatus;
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
// A top-level result, or a shapeless-but-valid table → success (read ops fail OPEN).
|
|
280
|
+
if (body.result) { out.ok = true; out.result = body.result; return out; }
|
|
281
|
+
if (Array.isArray(body.columns) || Array.isArray(body.rows)) {
|
|
282
|
+
out.ok = true; out.result = { columns: body.columns || [], rows: body.rows || [] }; return out;
|
|
283
|
+
}
|
|
284
|
+
if (httpStatus >= 400) { out.error = { status: httpStatus, detail: "control plane reported failure" }; return out; }
|
|
285
|
+
out.ok = true; out.result = { columns: [], rows: [] };
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Turn a {columns,rows} result into column-keyed records. Rows may be positional arrays
|
|
290
|
+
// (aligned to columns) or already-keyed objects - both are accepted.
|
|
291
|
+
function recordsFrom(result) {
|
|
292
|
+
if (!result || !Array.isArray(result.rows)) return [];
|
|
293
|
+
const cols = Array.isArray(result.columns) ? result.columns : [];
|
|
294
|
+
return result.rows.map((row) => {
|
|
295
|
+
if (!Array.isArray(row)) return row && typeof row === "object" ? row : { value: row };
|
|
296
|
+
const m = {};
|
|
297
|
+
cols.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
|
|
298
|
+
return m;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveKey(opts) {
|
|
303
|
+
const key = (opts && opts.apiKey) || process.env.WHISPER_API_KEY;
|
|
304
|
+
return key && String(key).trim();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The one place that talks to the control plane. Builds the injection-proof query, POSTs
|
|
308
|
+
// it with the key in X-API-Key, decodes either envelope shape, and throws WhisperError
|
|
309
|
+
// (carrying the server's helpful `detail`, plus .status/.retryAfter) on ok:false. The key
|
|
310
|
+
// is never logged, never placed on argv, never returned.
|
|
311
|
+
async function callControl(op, args, opts = {}) {
|
|
312
|
+
const key = resolveKey(opts);
|
|
313
|
+
if (!key) {
|
|
314
|
+
throw new WhisperError(
|
|
315
|
+
`\`${op}\` needs a Whisper API key - pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
316
|
+
"(verify/verifyDetails/rdap are keyless and need no key.)"
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const url = controlUrl();
|
|
320
|
+
const timeout = opts.timeout || 60000;
|
|
321
|
+
const ctrl = new AbortController();
|
|
322
|
+
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
323
|
+
let res;
|
|
324
|
+
try {
|
|
325
|
+
res = await fetch(url, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: {
|
|
328
|
+
"Content-Type": "application/json",
|
|
329
|
+
Accept: "application/json",
|
|
330
|
+
"X-API-Key": key,
|
|
331
|
+
"User-Agent": UA,
|
|
332
|
+
},
|
|
333
|
+
body: JSON.stringify({ query: buildAgentsQuery(op, args || {}) }),
|
|
334
|
+
signal: ctrl.signal,
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
throw new WhisperError(`could not reach the Whisper control plane at ${url}: ${err && err.message ? err.message : err}`);
|
|
338
|
+
} finally {
|
|
339
|
+
clearTimeout(t);
|
|
340
|
+
}
|
|
341
|
+
let body = null;
|
|
342
|
+
try {
|
|
343
|
+
body = await res.json();
|
|
344
|
+
} catch {
|
|
345
|
+
body = null;
|
|
346
|
+
}
|
|
347
|
+
const env = decodeEnvelope(body, res.status);
|
|
348
|
+
if (!env.ok) {
|
|
349
|
+
const e = env.error || {};
|
|
350
|
+
const werr = new WhisperError(e.detail || e.title || e.type || `control plane returned HTTP ${env.status}`);
|
|
351
|
+
werr.status = env.status;
|
|
352
|
+
if (env.retryAfter != null) werr.retryAfter = env.retryAfter;
|
|
353
|
+
if (e.suggestions) werr.suggestions = e.suggestions;
|
|
354
|
+
throw werr;
|
|
355
|
+
}
|
|
356
|
+
return recordsFrom(env.result);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* List your fleet (keyed control plane). `kind`: "agents" (default) | "identities" | "records".
|
|
361
|
+
* Liberal input: `list("identities")` or `list({ kind, apiKey })`. → an array of items
|
|
362
|
+
* (`{label, fqdn, address, agent, created, state}`), newest-first is up to the caller.
|
|
363
|
+
*/
|
|
364
|
+
export async function list(opts = {}) {
|
|
365
|
+
const o = typeof opts === "string" ? { kind: opts } : opts || {};
|
|
366
|
+
const recs = await callControl("list", { kind: o.kind || "agents" }, o);
|
|
367
|
+
// op:list rows are {kind,item}; unwrap to the item map when present.
|
|
368
|
+
return recs.map((r) => (r && typeof r.item === "object" && r.item !== null ? r.item : r));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Read or set your per-tenant DNS resolver policy (op:policy). NO policy fields ⇒ READ.
|
|
373
|
+
* To set: `policy({ default:"deny", block:["x.com"], allow:["y.com"] })`. → an array of
|
|
374
|
+
* `{ key, value }` rows (the effective policy).
|
|
375
|
+
*/
|
|
376
|
+
export async function policy(opts = {}) {
|
|
377
|
+
const o = opts || {};
|
|
378
|
+
const args = {};
|
|
379
|
+
if (o.default != null && o.default !== "") args.default = o.default;
|
|
380
|
+
if (Array.isArray(o.allow) && o.allow.length) args.allow = o.allow;
|
|
381
|
+
if (Array.isArray(o.block) && o.block.length) args.block = o.block;
|
|
382
|
+
return callControl("policy", args, o);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Query recent activity from warm storage (op:logs) - the poll counterpart of the live
|
|
387
|
+
* monitor. All filters optional: `{ agent, kind:"dns"|"conn"|"alloc", from, to, limit }`
|
|
388
|
+
* (from/to accept epoch-ms, RFC-3339, or relative like "-1h"). → an array of event records.
|
|
389
|
+
*/
|
|
390
|
+
export async function logs(opts = {}) {
|
|
391
|
+
const o = opts || {};
|
|
392
|
+
const args = {};
|
|
393
|
+
if (o.agent) args.agent = o.agent;
|
|
394
|
+
if (o.kind) args.kind = o.kind;
|
|
395
|
+
if (o.from != null && o.from !== "") args.from = o.from;
|
|
396
|
+
if (o.to != null && o.to !== "") args.to = o.to;
|
|
397
|
+
if (o.limit != null && Number(o.limit) > 0) args.limit = Number(o.limit);
|
|
398
|
+
return callControl("logs", args, o);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
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
|
+
*/
|
|
406
|
+
export async function revoke(target, maybeOpts) {
|
|
407
|
+
const o = typeof target === "string" ? maybeOpts || {} : target || {};
|
|
408
|
+
const sel = typeof target === "string" ? target : o.agent;
|
|
409
|
+
if (!sel || !String(sel).trim()) throw new WhisperError("revoke() needs an agent id or /128 address");
|
|
410
|
+
const recs = await callControl("revoke", { agent: String(sel).trim() }, o);
|
|
411
|
+
const rec = recs.length ? recs[0] : {};
|
|
412
|
+
return { status: rec.status || rec.state || "submitted", ...rec };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Allocate your own /128 identity (op:identity): `identity({ label, contact_email })`.
|
|
417
|
+
* Release form: `identity({ release:true, address:"2a04:2a01:..." })`.
|
|
418
|
+
* → `{ agent, address, fqdn, ptr, state }`.
|
|
419
|
+
*/
|
|
420
|
+
export async function identity(opts = {}) {
|
|
421
|
+
const o = opts || {};
|
|
422
|
+
const args = {};
|
|
423
|
+
if (o.release) {
|
|
424
|
+
if (!o.address || !String(o.address).trim()) throw new WhisperError("identity({ release:true }) needs the address to release");
|
|
425
|
+
args.release = true;
|
|
426
|
+
args.address = String(o.address).trim();
|
|
427
|
+
} else {
|
|
428
|
+
if (!o.label || !String(o.label).trim()) throw new WhisperError("identity() needs a label (the agent's name)");
|
|
429
|
+
args.label = String(o.label).trim();
|
|
430
|
+
if (o.contact_email) args.contact_email = o.contact_email;
|
|
431
|
+
}
|
|
432
|
+
const recs = await callControl("identity", args, o);
|
|
433
|
+
return recs.length ? recs[0] : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One agent's detail + live counters (op:agent). Liberal input: `agent("2a04:2a01:...")`,
|
|
438
|
+
* `agent("agent-id")`, or `agent({ agent })` / `agent({ address })`. A value with a colon
|
|
439
|
+
* is treated as a /128 address. → the detail record, or null if not in your account.
|
|
440
|
+
*/
|
|
441
|
+
export async function agent(sel = {}, maybeOpts) {
|
|
442
|
+
const o = typeof sel === "string" ? maybeOpts || {} : sel || {};
|
|
443
|
+
const args = {};
|
|
444
|
+
if (typeof sel === "string") {
|
|
445
|
+
if (sel.includes(":")) args.address = sel.trim();
|
|
446
|
+
else args.agent = sel.trim();
|
|
447
|
+
} else if (o.address) {
|
|
448
|
+
args.address = String(o.address).trim();
|
|
449
|
+
} else if (o.agent) {
|
|
450
|
+
if (String(o.agent).includes(":")) args.address = String(o.agent).trim();
|
|
451
|
+
else args.agent = String(o.agent).trim();
|
|
452
|
+
}
|
|
453
|
+
if (!args.agent && !args.address) throw new WhisperError("agent() needs an agent id or /128 address");
|
|
454
|
+
const recs = await callControl("agent", args, o);
|
|
455
|
+
return recs.length ? recs[0] : null;
|
|
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 tables below are copied from it (keyed by exec.mode). A test
|
|
470
|
+
// asserts the count so the two cannot silently drift. 14 `direct` entries run their catalog
|
|
471
|
+
// Cypher against /api/query; 15 `flow` entries run via the workflow runner (run_workflow),
|
|
472
|
+
// which is NOT a single /api/query Cypher, so they are shipped as clearly-noted stubs and
|
|
473
|
+
// are not wired to an engine here. graph.query() is the raw escape hatch.
|
|
474
|
+
|
|
475
|
+
function graphUrl() {
|
|
476
|
+
// Defaults to the control endpoint, which already IS the graph endpoint. Override for tests
|
|
477
|
+
// or a private deployment. WHISPER_GRAPH_URL wins, then the shared control URL.
|
|
478
|
+
return process.env.WHISPER_GRAPH_URL || controlUrl();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Normalise the /api/query reply into { columns, rows, statistics }. Per the contract rows
|
|
482
|
+
// are OBJECTS keyed by column name; we also accept a positional-array row (aligned to
|
|
483
|
+
// columns) so a liberal server shape still decodes. Never throws on shape.
|
|
484
|
+
function normalizeGraph(body) {
|
|
485
|
+
if (!body || typeof body !== "object") return { columns: [], rows: [], statistics: null };
|
|
486
|
+
const columns = Array.isArray(body.columns) ? body.columns : [];
|
|
487
|
+
const rawRows = Array.isArray(body.rows) ? body.rows : [];
|
|
488
|
+
const rows = rawRows.map((row) => {
|
|
489
|
+
if (Array.isArray(row)) {
|
|
490
|
+
const m = {};
|
|
491
|
+
columns.forEach((c, i) => { if (i < row.length) m[c] = row[i]; });
|
|
492
|
+
return m;
|
|
493
|
+
}
|
|
494
|
+
return row && typeof row === "object" ? row : { value: row };
|
|
495
|
+
});
|
|
496
|
+
return { columns, rows, statistics: body.statistics || null };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The one place that talks to the graph. Requires a key (via the shared resolveKey), POSTs
|
|
500
|
+
// {query,parameters}, and on an HTTP>=400 or an RFC-7807 problem body throws a WhisperError
|
|
501
|
+
// carrying the server's helpful detail (plus .status/.retryAfter). The key is only ever sent
|
|
502
|
+
// in X-API-Key: never logged, never on argv, never returned.
|
|
503
|
+
async function callGraph(cypher, params, opts = {}, methodName = "query") {
|
|
504
|
+
const key = resolveKey(opts);
|
|
505
|
+
if (!key) {
|
|
506
|
+
throw new WhisperError(
|
|
507
|
+
`\`graph.${methodName}\` needs a Whisper API key: pass { apiKey } or set WHISPER_API_KEY. ` +
|
|
508
|
+
"(verify/verifyDetails/rdap are keyless and need no key; graph queries are Cypher, so they do.)"
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const url = graphUrl();
|
|
512
|
+
const timeout = opts.timeout || 60000;
|
|
513
|
+
const ctrl = new AbortController();
|
|
514
|
+
const t = setTimeout(() => ctrl.abort(), timeout);
|
|
515
|
+
let res;
|
|
516
|
+
try {
|
|
517
|
+
res = await fetch(url, {
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: {
|
|
520
|
+
"Content-Type": "application/json",
|
|
521
|
+
Accept: "application/json",
|
|
522
|
+
"X-API-Key": key,
|
|
523
|
+
"User-Agent": UA,
|
|
524
|
+
},
|
|
525
|
+
body: JSON.stringify({ query: cypher, parameters: params || {} }),
|
|
526
|
+
signal: ctrl.signal,
|
|
527
|
+
});
|
|
528
|
+
} catch (err) {
|
|
529
|
+
throw new WhisperError(`could not reach the Whisper graph at ${url}: ${err && err.message ? err.message : err}`);
|
|
530
|
+
} finally {
|
|
531
|
+
clearTimeout(t);
|
|
532
|
+
}
|
|
533
|
+
let body = null;
|
|
534
|
+
try {
|
|
535
|
+
body = await res.json();
|
|
536
|
+
} catch {
|
|
537
|
+
body = null;
|
|
538
|
+
}
|
|
539
|
+
// A fault is either an HTTP error status or a problem body (has error/detail/title but no
|
|
540
|
+
// result table). A valid table always carries columns/rows, so those are never a fault.
|
|
541
|
+
const hasTable = body && typeof body === "object" && (Array.isArray(body.columns) || Array.isArray(body.rows));
|
|
542
|
+
const problem = body && typeof body === "object" && (body.error || (!hasTable && (body.detail || body.title || body.type)));
|
|
543
|
+
if (res.status >= 400 || problem) {
|
|
544
|
+
const e = (body && (body.error || body)) || {};
|
|
545
|
+
const werr = new WhisperError(e.detail || e.title || e.message || e.type || `graph query returned HTTP ${res.status}`);
|
|
546
|
+
werr.status = e.status || res.status;
|
|
547
|
+
if (e.retry_after != null) werr.retryAfter = e.retry_after;
|
|
548
|
+
throw werr;
|
|
549
|
+
}
|
|
550
|
+
return normalizeGraph(body);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The 14 `direct` catalog entries: id (camelCased) -> its catalog Cypher. Every one binds a
|
|
554
|
+
// single input as $v (paramName `v` in the catalog), except dbSchema (no input) and submit
|
|
555
|
+
// (a write map, handled below). Copied verbatim from whisper-catalog/catalog.json.
|
|
556
|
+
const GRAPH_DIRECT = {
|
|
557
|
+
identify: "CALL whisper.identify([$v]) YIELD host, vendor_id, canonical_name, category, roles, host_class, band",
|
|
558
|
+
assess: "CALL whisper.assess([$v]) YIELD host, label, band, sub_labels, coverage, evidence",
|
|
559
|
+
variants: "CALL whisper.variants($v) YIELD variant, method, exists, confidence",
|
|
560
|
+
walk: "CALL whisper.walk($v) YIELD coverage, host, nearest_known_vendors, no_atlas_match, siblings",
|
|
561
|
+
explain: "CALL whisper.explain($v) YIELD indicator, score, level, explanation, sources",
|
|
562
|
+
pslTldplusone: "CALL whisper.psl.tldPlusOne($v) YIELD apex",
|
|
563
|
+
pslAffiliation: "CALL whisper.psl.affiliation($v) YIELD found, suffix, submitterOrg, submitterLogin, evidenceKind, confidence",
|
|
564
|
+
origins: "CALL whisper.origins($v) YIELD ip, confidence, methods, asn, asnName, kind",
|
|
565
|
+
history: "CALL whisper.history($v)",
|
|
566
|
+
historyWhois: "CALL whisper.history.whois($v) YIELD queryTime, createDate, updateDate, expiryDate, registrar, registrant, country, nameServers",
|
|
567
|
+
asset: "CALL whisper.asSet($v) YIELD asSetName, memberAsn, sourceRir",
|
|
568
|
+
lookupTorRelay: "CALL whisper.lookupTorRelay($v) YIELD indicator, found, fingerprint, exitAddressCount, source, ingestedAt",
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// The 15 `flow` catalog entries: id (camelCased) -> its runVia slug. These are multi-step
|
|
572
|
+
// read workflows run by the console/agent workflow runner, NOT a single /api/query Cypher,
|
|
573
|
+
// so they are not wired to an engine in this SDK. Copied verbatim from the catalog.
|
|
574
|
+
const GRAPH_FLOW = {
|
|
575
|
+
anycastDnsRootSovereignty: "run_workflow (anycast-dns-root-sovereignty)",
|
|
576
|
+
attackPath: "run_workflow (attack-path)",
|
|
577
|
+
attackSurface: "run_workflow (attack-surface)",
|
|
578
|
+
bgpHijackExposure: "run_workflow (bgp-hijack-exposure)",
|
|
579
|
+
blastRadius: "run_workflow (blast-radius)",
|
|
580
|
+
buildTakedownEvidencePackage: "run_workflow (build-takedown-evidence-package)",
|
|
581
|
+
discoverAiAgentInfrastructure: "run_workflow (discover-ai-agent-infrastructure)",
|
|
582
|
+
indicator: "run_workflow (indicator)",
|
|
583
|
+
indicatorEnrichment: "run_workflow (indicator-enrichment)",
|
|
584
|
+
infrastructureMapping: "run_workflow (infrastructure-mapping)",
|
|
585
|
+
mapSupplyChainConcentration: "run_workflow (map-supply-chain-concentration)",
|
|
586
|
+
nameserverHijackDnsConsistency: "run_workflow (nameserver-hijack-dns-consistency)",
|
|
587
|
+
routeHealth: "run_workflow (route-health)",
|
|
588
|
+
subdomainTakeover: "run_workflow (subdomain-takeover)",
|
|
589
|
+
typosquat: "run_workflow (typosquat)",
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// Coerce a liberal first arg into the string value bound as $v. Accepts a bare value or a
|
|
593
|
+
// { value } / { v } wrapper; empty is rejected with a clear error, never an opaque failure.
|
|
594
|
+
function graphValue(name, value) {
|
|
595
|
+
let v = value;
|
|
596
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
597
|
+
if (v.value !== undefined) v = v.value;
|
|
598
|
+
else if (v.v !== undefined) v = v.v;
|
|
599
|
+
}
|
|
600
|
+
if (v === null || v === undefined || String(v).trim() === "") {
|
|
601
|
+
throw new WhisperError(`graph.${name}() needs a value (a host, IP, domain, or ASN)`);
|
|
602
|
+
}
|
|
603
|
+
return String(v).trim();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Run raw Cypher against the Whisper Security graph (keyed). The escape hatch under every
|
|
608
|
+
* typed method: `graph.query("MATCH (n) RETURN n LIMIT 1", {}, { apiKey })`. Returns the
|
|
609
|
+
* full `{ columns, rows, statistics }` (rows are objects keyed by column name).
|
|
610
|
+
*/
|
|
611
|
+
async function graphQuery(cypher, params, opts = {}) {
|
|
612
|
+
if (!cypher || !String(cypher).trim()) throw new WhisperError("graph.query() needs a Cypher string");
|
|
613
|
+
return callGraph(String(cypher), params || {}, opts, "query");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Build the namespace: query + a typed method per catalog entry. Direct methods run their
|
|
617
|
+
// Cypher; flow methods throw a clear, honest "runs via the workflow runner, not wired here"
|
|
618
|
+
// error (never a silent no-op) and carry .runVia for programmatic inspection.
|
|
619
|
+
const graph = { query: graphQuery };
|
|
620
|
+
|
|
621
|
+
for (const [name, cypher] of Object.entries(GRAPH_DIRECT)) {
|
|
622
|
+
graph[name] = async (value, opts = {}) => callGraph(cypher, { v: graphValue(name, value) }, opts, name);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** List every node and relationship type in the graph with counts and examples (keyed). No input. */
|
|
626
|
+
graph.dbSchema = async (opts = {}) => callGraph("CALL db.schema()", {}, opts, "dbSchema");
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Contribute an indicator observation or feedback back into the graph (keyed WRITE). Pass
|
|
630
|
+
* `{ kind: "indicator" | "feedback", ...fields }`. The payload is bound as a single Cypher
|
|
631
|
+
* parameter (injection-safe). The server enforces its own write scopes and rejects anonymous
|
|
632
|
+
* callers, so a scope/quota fault surfaces as the server's WhisperError detail.
|
|
633
|
+
*/
|
|
634
|
+
graph.submit = async (payload, opts = {}) => {
|
|
635
|
+
const p = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
636
|
+
if (!p.kind || String(p.kind).trim() === "") {
|
|
637
|
+
throw new WhisperError("graph.submit() needs a { kind } of 'indicator' or 'feedback' (plus its fields)");
|
|
638
|
+
}
|
|
639
|
+
return callGraph("CALL whisper.submit($submission)", { submission: p }, opts, "submit");
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
for (const [name, runVia] of Object.entries(GRAPH_FLOW)) {
|
|
643
|
+
graph[name] = () => {
|
|
644
|
+
const err = new WhisperError(
|
|
645
|
+
`graph.${name}() is a multi-step read workflow: it runs via the Whisper workflow runner ` +
|
|
646
|
+
`(${runVia}), not a single /api/query Cypher, and is not wired into this SDK yet. Use ` +
|
|
647
|
+
"graph.query() for a raw Cypher call, or run the workflow via the console/agent runner."
|
|
648
|
+
);
|
|
649
|
+
err.runVia = runVia;
|
|
650
|
+
return Promise.reject(err);
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export { graph };
|
package/package.json
CHANGED