@crewhaus/tool-notify 0.7.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 +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Have I already told them about this?" — as a pure function over a state
|
|
3
|
+
* record the caller keeps.
|
|
4
|
+
*
|
|
5
|
+
* A harness that notices the same broken thing on every loop iteration will
|
|
6
|
+
* notify about it on every loop iteration unless something remembers. That
|
|
7
|
+
* memory has to live somewhere the harness controls (a KV entry, a file, a
|
|
8
|
+
* field on its own state) rather than inside a tool, because a tool's
|
|
9
|
+
* process-local memory dies with the process and the flood comes back after
|
|
10
|
+
* the first restart. So this takes the state in and hands the next state
|
|
11
|
+
* back, and the caller persists it wherever its state already lives.
|
|
12
|
+
*
|
|
13
|
+
* Fixed windows, not a sliding log. A sliding window needs every timestamp
|
|
14
|
+
* retained, which grows without bound in exactly the situation the gate
|
|
15
|
+
* exists for; a fixed window costs two numbers per key and is easy to
|
|
16
|
+
* reason about out loud ("at most 3 per hour per key").
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* How many expired windows to keep before pruning. A state record that only
|
|
20
|
+
* ever grows is a slow leak in whatever the harness persists it to, so
|
|
21
|
+
* entries whose window ended more than `windowMs` ago are dropped — they can
|
|
22
|
+
* no longer refuse anything, so nothing is lost by forgetting them.
|
|
23
|
+
*/
|
|
24
|
+
function prune(state, nowMs, windowMs, keep) {
|
|
25
|
+
const kept = {};
|
|
26
|
+
const pruned = [];
|
|
27
|
+
for (const name of Object.keys(state).sort()) {
|
|
28
|
+
const entry = state[name];
|
|
29
|
+
if (name !== keep && nowMs - entry.windowStart >= windowMs * 2) {
|
|
30
|
+
pruned.push(name);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
kept[name] = entry;
|
|
34
|
+
}
|
|
35
|
+
return { kept, pruned };
|
|
36
|
+
}
|
|
37
|
+
/** Decide, and hand back the record to persist. */
|
|
38
|
+
export function rateLimitGate(options) {
|
|
39
|
+
const limit = Math.max(1, options.limit ?? 1);
|
|
40
|
+
const windowMs = Math.max(1, options.windowMs);
|
|
41
|
+
const state = options.state ?? {};
|
|
42
|
+
const { kept, pruned } = prune(state, options.nowMs, windowMs, options.key);
|
|
43
|
+
const existing = kept[options.key];
|
|
44
|
+
const inWindow = existing !== undefined && options.nowMs - existing.windowStart < windowMs;
|
|
45
|
+
const current = inWindow ? existing : undefined;
|
|
46
|
+
const count = current?.count ?? 0;
|
|
47
|
+
if (count >= limit) {
|
|
48
|
+
const windowStart = current.windowStart;
|
|
49
|
+
const resetAt = windowStart + windowMs;
|
|
50
|
+
return {
|
|
51
|
+
allowed: false,
|
|
52
|
+
reason: `"${options.key}" has already been notified ${count} time(s) in this window of ${windowMs}ms, which is the limit`,
|
|
53
|
+
count,
|
|
54
|
+
limit,
|
|
55
|
+
retryAfterMs: Math.max(0, resetAt - options.nowMs),
|
|
56
|
+
retryAt: new Date(resetAt).toISOString(),
|
|
57
|
+
nextState: kept,
|
|
58
|
+
pruned,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (options.mode === "peek") {
|
|
62
|
+
return {
|
|
63
|
+
allowed: true,
|
|
64
|
+
reason: `"${options.key}" is under the limit; nothing was recorded because mode is "peek"`,
|
|
65
|
+
count,
|
|
66
|
+
limit,
|
|
67
|
+
nextState: kept,
|
|
68
|
+
pruned,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const next = { ...kept };
|
|
72
|
+
next[options.key] = {
|
|
73
|
+
windowStart: current?.windowStart ?? options.nowMs,
|
|
74
|
+
count: count + 1,
|
|
75
|
+
last: options.nowMs,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
allowed: true,
|
|
79
|
+
reason: count === 0
|
|
80
|
+
? `"${options.key}" has not been notified in this window`
|
|
81
|
+
: `"${options.key}" is under the limit of ${limit} per ${windowMs}ms`,
|
|
82
|
+
count: count + 1,
|
|
83
|
+
limit,
|
|
84
|
+
nextState: next,
|
|
85
|
+
pruned,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type SignatureScheme = "timestamped" | "body";
|
|
2
|
+
export type SignatureAlgorithm = "sha256" | "sha1";
|
|
3
|
+
/** The string a scheme runs the MAC over. */
|
|
4
|
+
export declare function signedPayload(scheme: SignatureScheme, body: string, timestampSeconds?: number): string;
|
|
5
|
+
/** Lowercase hex HMAC. */
|
|
6
|
+
export declare function hmacHex(secret: string, payload: string, algorithm?: SignatureAlgorithm): string;
|
|
7
|
+
/** The header value a sender transmits for this signature. */
|
|
8
|
+
export declare function formatSignatureHeader(scheme: SignatureScheme, hex: string, algorithm: SignatureAlgorithm, timestampSeconds?: number): string;
|
package/dist/lib/sign.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook signing, in the two shapes providers actually ship.
|
|
3
|
+
*
|
|
4
|
+
* "timestamped" — Stripe's scheme. The MAC covers `<unix-seconds>.<body>`
|
|
5
|
+
* and the header reads `t=<unix-seconds>,v1=<hex>`. The timestamp is
|
|
6
|
+
* inside the MAC, so a receiver can enforce a replay window.
|
|
7
|
+
*
|
|
8
|
+
* "body" — GitHub's scheme. The MAC covers the raw body and the header
|
|
9
|
+
* reads `sha256=<hex>`. There is no timestamp and therefore no replay
|
|
10
|
+
* protection; naming the schemes by what they cover rather than by
|
|
11
|
+
* vendor is how that stays visible.
|
|
12
|
+
*
|
|
13
|
+
* The MAC must run over the EXACT bytes that are transmitted. The signer
|
|
14
|
+
* therefore takes the serialised body, and the tool signs the same string it
|
|
15
|
+
* puts on the wire — re-serialising a parsed object between signing and
|
|
16
|
+
* sending changes the bytes and every verification downstream fails.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reads the clock: `timestampSeconds` is an argument.
|
|
19
|
+
*/
|
|
20
|
+
import { createHmac } from "node:crypto";
|
|
21
|
+
/** The string a scheme runs the MAC over. */
|
|
22
|
+
export function signedPayload(scheme, body, timestampSeconds) {
|
|
23
|
+
if (scheme === "body")
|
|
24
|
+
return body;
|
|
25
|
+
if (timestampSeconds === undefined) {
|
|
26
|
+
throw new Error('the "timestamped" scheme needs a timestamp');
|
|
27
|
+
}
|
|
28
|
+
return `${timestampSeconds}.${body}`;
|
|
29
|
+
}
|
|
30
|
+
/** Lowercase hex HMAC. */
|
|
31
|
+
export function hmacHex(secret, payload, algorithm = "sha256") {
|
|
32
|
+
return createHmac(algorithm, secret).update(payload, "utf8").digest("hex");
|
|
33
|
+
}
|
|
34
|
+
/** The header value a sender transmits for this signature. */
|
|
35
|
+
export function formatSignatureHeader(scheme, hex, algorithm, timestampSeconds) {
|
|
36
|
+
if (scheme === "body")
|
|
37
|
+
return `${algorithm}=${hex}`;
|
|
38
|
+
return `t=${timestampSeconds},v1=${hex}`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message templates, where the operator owns the structure and the model
|
|
3
|
+
* only ever supplies values.
|
|
4
|
+
*
|
|
5
|
+
* `@crewhaus/tool-text`'s `RenderTemplate` fills placeholders in a string.
|
|
6
|
+
* This is the same idea with one extra rule that matters here and nowhere
|
|
7
|
+
* else: the values are escaped for the platform the message is going to,
|
|
8
|
+
* BEFORE they are substituted. A deploy summary containing `<!channel>` or
|
|
9
|
+
* ```` ``` ```` would otherwise change what the message does, not just what
|
|
10
|
+
* it says — which is the whole reason a harness should not be assembling
|
|
11
|
+
* notification strings by hand.
|
|
12
|
+
*
|
|
13
|
+
* Missing keys are an error, never an empty string. A notification that
|
|
14
|
+
* silently reads "deploy of failed at " is worse than one that did not go
|
|
15
|
+
* out, because somebody acts on it.
|
|
16
|
+
*/
|
|
17
|
+
import type { Platform } from "./blocks";
|
|
18
|
+
export type RenderResult = {
|
|
19
|
+
readonly ok: true;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
/** Placeholder names that were filled, sorted and de-duplicated. */
|
|
22
|
+
readonly used: readonly string[];
|
|
23
|
+
/** Keys present in the data that no placeholder asked for. */
|
|
24
|
+
readonly unused: readonly string[];
|
|
25
|
+
} | {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly missing: readonly string[];
|
|
28
|
+
};
|
|
29
|
+
/** All the placeholder names a template refers to, in first-seen order. */
|
|
30
|
+
export declare function templatePlaceholders(template: string): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Fill `template` from `data`, escaping every substituted value for
|
|
33
|
+
* `platform`.
|
|
34
|
+
*
|
|
35
|
+
* The template itself is NOT escaped: it is operator-authored, so its `*`
|
|
36
|
+
* and `<…|…>` are the formatting the operator intended. Only the values are,
|
|
37
|
+
* which is exactly the boundary that makes the result safe — a value can
|
|
38
|
+
* never introduce structure, and the structure can never be introduced by
|
|
39
|
+
* anything but the template.
|
|
40
|
+
*/
|
|
41
|
+
export declare function renderTemplate(template: string, data: Readonly<Record<string, unknown>>, platform: Platform): RenderResult;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { escapeFor } from "./blocks";
|
|
2
|
+
/** `{{ name }}` or `{{ a.b.c }}`; whitespace inside the braces is ignored. */
|
|
3
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.]*)\s*\}\}/g;
|
|
4
|
+
/** Read a dotted path out of the data object. */
|
|
5
|
+
function lookup(data, path) {
|
|
6
|
+
let current = data;
|
|
7
|
+
for (const segment of path.split(".")) {
|
|
8
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
current = current[segment];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* How a value becomes text.
|
|
17
|
+
*
|
|
18
|
+
* Numbers and booleans are written out; `null` and `undefined` count as
|
|
19
|
+
* missing rather than rendering the word "null"; objects and arrays are
|
|
20
|
+
* JSON-encoded, because a template that interpolates a structure wants to
|
|
21
|
+
* SHOW it, and `[object Object]` in a production alert helps nobody.
|
|
22
|
+
*/
|
|
23
|
+
function stringify(value) {
|
|
24
|
+
if (value === null || value === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (typeof value === "string")
|
|
27
|
+
return value;
|
|
28
|
+
if (typeof value === "number")
|
|
29
|
+
return Number.isFinite(value) ? String(value) : null;
|
|
30
|
+
if (typeof value === "boolean")
|
|
31
|
+
return String(value);
|
|
32
|
+
if (typeof value === "bigint")
|
|
33
|
+
return value.toString();
|
|
34
|
+
try {
|
|
35
|
+
return JSON.stringify(value) ?? null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** All the placeholder names a template refers to, in first-seen order. */
|
|
42
|
+
export function templatePlaceholders(template) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
46
|
+
const name = match[1];
|
|
47
|
+
if (!seen.has(name)) {
|
|
48
|
+
seen.add(name);
|
|
49
|
+
out.push(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fill `template` from `data`, escaping every substituted value for
|
|
56
|
+
* `platform`.
|
|
57
|
+
*
|
|
58
|
+
* The template itself is NOT escaped: it is operator-authored, so its `*`
|
|
59
|
+
* and `<…|…>` are the formatting the operator intended. Only the values are,
|
|
60
|
+
* which is exactly the boundary that makes the result safe — a value can
|
|
61
|
+
* never introduce structure, and the structure can never be introduced by
|
|
62
|
+
* anything but the template.
|
|
63
|
+
*/
|
|
64
|
+
export function renderTemplate(template, data, platform) {
|
|
65
|
+
const missing = [];
|
|
66
|
+
const used = new Set();
|
|
67
|
+
const text = template.replace(PLACEHOLDER, (_whole, rawName) => {
|
|
68
|
+
const name = rawName;
|
|
69
|
+
const value = stringify(lookup(data, name));
|
|
70
|
+
if (value === null) {
|
|
71
|
+
missing.push(name);
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
used.add(name);
|
|
75
|
+
return escapeFor(platform, value);
|
|
76
|
+
});
|
|
77
|
+
if (missing.length > 0) {
|
|
78
|
+
return { ok: false, missing: [...new Set(missing)].sort() };
|
|
79
|
+
}
|
|
80
|
+
const referenced = new Set(templatePlaceholders(template).map((n) => n.split(".")[0]));
|
|
81
|
+
const unused = Object.keys(data)
|
|
82
|
+
.filter((key) => !referenced.has(key))
|
|
83
|
+
.sort();
|
|
84
|
+
return { ok: true, text, used: [...used].sort(), unused };
|
|
85
|
+
}
|
package/dist/net.d.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
/** Refusal by the allow-list, the SSRF gate, or a redirect rule. */
|
|
3
|
+
export declare class NotifyPermissionError extends CrewhausError {
|
|
4
|
+
readonly name = "NotifyPermissionError";
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
export declare const MAX_TIMEOUT_MS = 300000;
|
|
9
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
10
|
+
export declare const MAX_MAX_BYTES: number;
|
|
11
|
+
export declare const MAX_REDIRECTS = 3;
|
|
12
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
13
|
+
export declare const json: (value: unknown) => string;
|
|
14
|
+
/** Locale-independent string order. `localeCompare` without a locale is not deterministic. */
|
|
15
|
+
export declare function byString(a: string, b: string): number;
|
|
16
|
+
/**
|
|
17
|
+
* A REST provider described by the operator rather than hard-coded here.
|
|
18
|
+
*
|
|
19
|
+
* `SmsSend`, `PushNotify` and `DeliveryCheck` all speak this shape. Naming a
|
|
20
|
+
* vendor in the code would mean a new release every time somebody moves from
|
|
21
|
+
* one gateway to another; naming the endpoint, the auth variable and the
|
|
22
|
+
* field mapping in the spec means the operator does it in a diff.
|
|
23
|
+
*/
|
|
24
|
+
export type ProviderProfile = {
|
|
25
|
+
/** Absolute https URL. Its origin must also be in `allowed_origins`. */
|
|
26
|
+
readonly endpoint: string;
|
|
27
|
+
/** POST unless the provider insists otherwise. */
|
|
28
|
+
readonly method?: string;
|
|
29
|
+
/** "json" (default) or "form" — how the mapped fields are encoded. */
|
|
30
|
+
readonly encoding?: "json" | "form";
|
|
31
|
+
readonly auth?: AuthProfile;
|
|
32
|
+
/**
|
|
33
|
+
* Canonical field name → the provider's own field name. `to`, `body`,
|
|
34
|
+
* `from`, `title`, `mediaUrl` and `data` are the names the tools use.
|
|
35
|
+
*/
|
|
36
|
+
readonly fields?: Readonly<Record<string, string>>;
|
|
37
|
+
/** Constant fields merged into every request (a sender id, an app id). */
|
|
38
|
+
readonly staticFields?: Readonly<Record<string, string>>;
|
|
39
|
+
/** Dotted path in the response holding the provider's message id. */
|
|
40
|
+
readonly idPath?: string;
|
|
41
|
+
/** Dotted path in the response holding a delivery status. */
|
|
42
|
+
readonly statusPath?: string;
|
|
43
|
+
/** URL template for `DeliveryCheck`; `{id}` is substituted. */
|
|
44
|
+
readonly statusEndpoint?: string;
|
|
45
|
+
/** Header carrying an idempotency key, when the provider honours one. */
|
|
46
|
+
readonly idempotencyHeader?: string;
|
|
47
|
+
};
|
|
48
|
+
export type NotifyConfig = {
|
|
49
|
+
/** Canonical origins. Empty (the default) denies every request. */
|
|
50
|
+
readonly allowedOrigins: ReadonlySet<string>;
|
|
51
|
+
/**
|
|
52
|
+
* Who email may be addressed to: a full address, or `*@domain`. Empty
|
|
53
|
+
* denies every recipient, which makes `EmailSend` unusable until an
|
|
54
|
+
* operator says who the harness is allowed to write to.
|
|
55
|
+
*/
|
|
56
|
+
readonly allowedRecipients: readonly string[];
|
|
57
|
+
/** SMTP hosts the client may dial. Empty denies every one. */
|
|
58
|
+
readonly allowedSmtpHosts: ReadonlySet<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Domains this harness may ask public DNS about, canonicalised. Empty
|
|
61
|
+
* denies every one, which makes `DeliverabilityCheck` unusable until an
|
|
62
|
+
* operator says which domains are theirs.
|
|
63
|
+
*
|
|
64
|
+
* A DNS query is not an HTTP request and so does not pass through the
|
|
65
|
+
* origin allow-list — but the name queried is still chosen by the caller
|
|
66
|
+
* and still leaves the machine, which is the shape of an exfiltration
|
|
67
|
+
* channel. This is the gate for that surface, and it is fail-closed like
|
|
68
|
+
* every other one here.
|
|
69
|
+
*/
|
|
70
|
+
readonly allowedSenderDomains: ReadonlySet<string>;
|
|
71
|
+
/** Named REST providers for SMS, push and delivery lookups. */
|
|
72
|
+
readonly providers: ReadonlyMap<string, ProviderProfile>;
|
|
73
|
+
};
|
|
74
|
+
export type NotifyConfigInput = {
|
|
75
|
+
readonly allowed_origins?: readonly string[];
|
|
76
|
+
readonly allowedOrigins?: readonly string[];
|
|
77
|
+
readonly allowed_recipients?: readonly string[];
|
|
78
|
+
readonly allowedRecipients?: readonly string[];
|
|
79
|
+
readonly allowed_smtp_hosts?: readonly string[];
|
|
80
|
+
readonly allowedSmtpHosts?: readonly string[];
|
|
81
|
+
readonly allowed_sender_domains?: readonly string[];
|
|
82
|
+
readonly allowedSenderDomains?: readonly string[];
|
|
83
|
+
readonly providers?: Readonly<Record<string, ProviderProfile>>;
|
|
84
|
+
};
|
|
85
|
+
/** Build a config from a spec-shaped block. Pure; both key spellings accepted. */
|
|
86
|
+
export declare function buildNotifyConfig(input: NotifyConfigInput): NotifyConfig;
|
|
87
|
+
/** Replace the process-global allow-list. Codegen calls this at boot. */
|
|
88
|
+
export declare function registerNotifyConfig(input: NotifyConfigInput): void;
|
|
89
|
+
export declare function getNotifyConfig(): NotifyConfig;
|
|
90
|
+
/**
|
|
91
|
+
* The config ONE call runs under: the serving candidate's
|
|
92
|
+
* `tool_config.notify` block when it declares one, else the boot
|
|
93
|
+
* registration. A non-object override is ignored rather than widened — the
|
|
94
|
+
* allow-list only ever comes from a spec block.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveNotifyConfig(override: unknown): NotifyConfig;
|
|
97
|
+
/** Test-only — back to fail-closed empty. */
|
|
98
|
+
export declare function _resetNotifyConfig(): void;
|
|
99
|
+
/**
|
|
100
|
+
* Canonicalise an origin for exact-match comparison: lowercase scheme and
|
|
101
|
+
* host, drop path/query/fragment, elide the default port. Throws on a
|
|
102
|
+
* malformed origin so a misconfiguration surfaces at boot, not at the first
|
|
103
|
+
* send.
|
|
104
|
+
*/
|
|
105
|
+
export declare function canonicalizeOrigin(raw: string): string;
|
|
106
|
+
/**
|
|
107
|
+
* The origin of a URL already parsed, without putting the URL back into any
|
|
108
|
+
* message. `label` is what a refusal may name — never the full URL, because
|
|
109
|
+
* a Slack or Teams incoming-webhook URL IS the credential, and an error
|
|
110
|
+
* string is a thing that ends up in a transcript.
|
|
111
|
+
*/
|
|
112
|
+
export declare function canonicalizeUrlOrigin(url: URL, label?: string): string;
|
|
113
|
+
/**
|
|
114
|
+
* How a webhook URL is allowed to appear in a message: scheme, host and a
|
|
115
|
+
* path length, with the path itself and the query dropped.
|
|
116
|
+
*
|
|
117
|
+
* A Slack incoming webhook (`/services/T…/B…/xxxx`) and a Discord one
|
|
118
|
+
* (`/api/webhooks/<id>/<token>`) both carry their whole authority in the
|
|
119
|
+
* path. Printing the path into a refusal would post the credential into the
|
|
120
|
+
* transcript, so only its shape is printed.
|
|
121
|
+
*/
|
|
122
|
+
export declare function safeUrlLabel(raw: string | URL): string;
|
|
123
|
+
/** Refuse a URL that carries `user:pass@`. */
|
|
124
|
+
export declare function assertNoUserinfo(url: URL): void;
|
|
125
|
+
/** Refuse a URL whose origin is not allow-listed. Empty list ⇒ refuse everything. */
|
|
126
|
+
export declare function assertOriginAllowed(url: URL, cfg: NotifyConfig): void;
|
|
127
|
+
/** Refuse an SMTP host no operator named. Empty list ⇒ refuse everything. */
|
|
128
|
+
export declare function assertSmtpHostAllowed(host: string, cfg: NotifyConfig): void;
|
|
129
|
+
/**
|
|
130
|
+
* Refuse a domain no operator named. Empty list ⇒ refuse everything.
|
|
131
|
+
*
|
|
132
|
+
* `domain` must ALREADY be the canonical form — the caller normalises once
|
|
133
|
+
* and then uses that one value for the gate, for the names it derives and
|
|
134
|
+
* for what it reports, so there is no spelling that passes the check and a
|
|
135
|
+
* different one that reaches the resolver.
|
|
136
|
+
*/
|
|
137
|
+
export declare function assertSenderDomainAllowed(domain: string, cfg: NotifyConfig): void;
|
|
138
|
+
/**
|
|
139
|
+
* Does `address` match an allow-list entry?
|
|
140
|
+
*
|
|
141
|
+
* An entry is either a whole address or `*@domain`. A bare domain is NOT
|
|
142
|
+
* accepted as a wildcard: `example.com` would read to a human as "anyone at
|
|
143
|
+
* example.com" and to this function as "the address example.com", and a gate
|
|
144
|
+
* whose entries mean one thing to the operator and another to the code is
|
|
145
|
+
* not a gate. Matching is case-insensitive on the whole address, which is
|
|
146
|
+
* looser than RFC 5321 (local-parts are technically case-sensitive) and the
|
|
147
|
+
* looseness is in the safe direction only for the domain — so a deliberately
|
|
148
|
+
* case-varied local part cannot slip a DIFFERENT mailbox past an entry,
|
|
149
|
+
* because the entry had to name that mailbox's spelling in the first place.
|
|
150
|
+
*/
|
|
151
|
+
export declare function recipientAllowed(address: string, cfg: NotifyConfig): boolean;
|
|
152
|
+
export type DnsLookupFn = (host: string) => Promise<{
|
|
153
|
+
readonly address: string;
|
|
154
|
+
readonly family: number;
|
|
155
|
+
}>;
|
|
156
|
+
/** Test-only resolver injection, as in `@crewhaus/tool-http`. */
|
|
157
|
+
export declare function _setDnsLookup(fn: DnsLookupFn | undefined): void;
|
|
158
|
+
/**
|
|
159
|
+
* Every TXT record at a name, each still split into the character-strings
|
|
160
|
+
* DNS carried it in. The joining rule belongs to the parser, not here, so
|
|
161
|
+
* the seam a test injects hands back exactly what a resolver hands back.
|
|
162
|
+
*/
|
|
163
|
+
export type DnsTxtFn = (name: string) => Promise<ReadonlyArray<ReadonlyArray<string>>>;
|
|
164
|
+
/** Test-only resolver injection. No test in this package may reach real DNS. */
|
|
165
|
+
export declare function _setDnsTxtResolver(fn: DnsTxtFn | undefined): void;
|
|
166
|
+
/**
|
|
167
|
+
* What a TXT lookup found — with "nothing is published" and "the question
|
|
168
|
+
* could not be answered" kept apart.
|
|
169
|
+
*
|
|
170
|
+
* This is the whole reason the type has four arms rather than returning
|
|
171
|
+
* `string[]`. "No SPF record" is a finding about a domain; "the resolver
|
|
172
|
+
* returned SERVFAIL" is a finding about the lookup, and reporting the second
|
|
173
|
+
* as the first tells somebody their DNS is fine when nobody asked it
|
|
174
|
+
* anything.
|
|
175
|
+
*/
|
|
176
|
+
export type TxtAnswer = {
|
|
177
|
+
readonly outcome: "records";
|
|
178
|
+
readonly records: readonly string[];
|
|
179
|
+
}
|
|
180
|
+
/** The name exists and publishes no TXT record. */
|
|
181
|
+
| {
|
|
182
|
+
readonly outcome: "none";
|
|
183
|
+
}
|
|
184
|
+
/** The name does not exist at all. */
|
|
185
|
+
| {
|
|
186
|
+
readonly outcome: "nxdomain";
|
|
187
|
+
} | {
|
|
188
|
+
readonly outcome: "unknown";
|
|
189
|
+
readonly reason: string;
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Ask for the TXT records at `name`.
|
|
193
|
+
*
|
|
194
|
+
* The two codes that ARE answers are mapped to answers: c-ares reports
|
|
195
|
+
* NXDOMAIN as `ENOTFOUND` and "the name exists but has no record of this
|
|
196
|
+
* type" as `ENODATA`, and both of those genuinely mean nothing is published.
|
|
197
|
+
* Everything else — SERVFAIL, a refused query, a timeout, a resolver that is
|
|
198
|
+
* not reachable — is `unknown` WITH the reason, because a lookup that did
|
|
199
|
+
* not happen must not read as a domain that published nothing.
|
|
200
|
+
*/
|
|
201
|
+
export declare function lookupTxt(name: string, deadline: Deadline): Promise<TxtAnswer>;
|
|
202
|
+
export declare function __setPrivateHostsAllowedForTest(allowed: boolean): void;
|
|
203
|
+
/**
|
|
204
|
+
* Reject a host that is — or resolves to — loopback, link-local, RFC1918,
|
|
205
|
+
* CGNAT, or mDNS. Returns the vetted IP so the caller can pin the socket to
|
|
206
|
+
* it instead of letting the stack re-resolve at connect time.
|
|
207
|
+
*/
|
|
208
|
+
export declare function assertNotSsrf(hostname: string): Promise<string>;
|
|
209
|
+
/**
|
|
210
|
+
* Canonicalise an IPv4 literal.
|
|
211
|
+
*
|
|
212
|
+
* `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
|
|
213
|
+
* `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
|
|
214
|
+
* only understands dotted-decimal waves every one of them through. Returns
|
|
215
|
+
* `null` when the string is not an IPv4 literal at all.
|
|
216
|
+
*/
|
|
217
|
+
export declare function normalizeIpv4(raw: string): string | null;
|
|
218
|
+
/**
|
|
219
|
+
* Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
|
|
220
|
+
* is not one.
|
|
221
|
+
*
|
|
222
|
+
* Classifying IPv6 by its TEXT is where the bypasses live, because one address
|
|
223
|
+
* has many spellings and the one a check was written against is rarely the one
|
|
224
|
+
* that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
|
|
225
|
+
* that form: the WHATWG URL parser re-serialises the embedded quad as hex
|
|
226
|
+
* pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
|
|
227
|
+
* first means the prefix tests below are arithmetic, and spelling stops
|
|
228
|
+
* mattering.
|
|
229
|
+
*/
|
|
230
|
+
export declare function parseIpv6(raw: string): ReadonlyArray<number> | null;
|
|
231
|
+
/** Ranges that are never a public RPC endpoint. */
|
|
232
|
+
export declare function isPrivateIp(address: string): boolean;
|
|
233
|
+
/**
|
|
234
|
+
* `parseIpv6` under this package's older name, returning a mutable array.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately OUTSIDE the synchronised block: `smtp.ts` uses it to decide
|
|
237
|
+
* whether a host is an IP literal and so has no SNI name, the SSRF gate
|
|
238
|
+
* re-emits an IPv6 literal in expanded form from it, and `lib.test.ts`
|
|
239
|
+
* imports it. The block's own text must not change, so the adaptation to
|
|
240
|
+
* this package's older name and signature lives here instead.
|
|
241
|
+
*/
|
|
242
|
+
export declare function expandIpv6(raw: string): number[] | null;
|
|
243
|
+
export type Deadline = {
|
|
244
|
+
readonly signal: AbortSignal;
|
|
245
|
+
/** Milliseconds left; never negative. */
|
|
246
|
+
remaining(): number;
|
|
247
|
+
expired(): boolean;
|
|
248
|
+
/** Clear the timer. Always call it, or the process keeps a handle alive. */
|
|
249
|
+
cancel(): void;
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* A deadline that also honours the runtime's own cancellation. Every
|
|
253
|
+
* outbound tool here opens one before its first byte and cancels it in a
|
|
254
|
+
* `finally` — a tool that can hang forever is a defect.
|
|
255
|
+
*/
|
|
256
|
+
export declare function startDeadline(ms: number, outer?: AbortSignal): Deadline;
|
|
257
|
+
/** Resolve after `ms`, or as soon as `signal` aborts. Never rejects. */
|
|
258
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
259
|
+
/**
|
|
260
|
+
* An auth profile names an ENVIRONMENT VARIABLE, never a literal secret. A
|
|
261
|
+
* model that can put a token in a tool argument is a model that can put it in
|
|
262
|
+
* a transcript, a trace event and an eval report.
|
|
263
|
+
*/
|
|
264
|
+
export type AuthProfile = {
|
|
265
|
+
readonly type: "bearer" | "basic" | "header";
|
|
266
|
+
readonly envVar: string;
|
|
267
|
+
/** Header name for `type: "header"` (e.g. `X-Api-Key`). */
|
|
268
|
+
readonly headerName?: string;
|
|
269
|
+
/** Username for `type: "basic"`; the password comes from `envVar`. */
|
|
270
|
+
readonly username?: string;
|
|
271
|
+
/** Literal prefix before the secret for `type: "header"` (e.g. `token `). */
|
|
272
|
+
readonly prefix?: string;
|
|
273
|
+
};
|
|
274
|
+
export type ResolvedSecret = {
|
|
275
|
+
readonly ok: true;
|
|
276
|
+
readonly value: string;
|
|
277
|
+
} | {
|
|
278
|
+
readonly ok: false;
|
|
279
|
+
readonly message: string;
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Read a secret out of the named environment variable.
|
|
283
|
+
*
|
|
284
|
+
* The NAME travels through the tool call; the value never does. A value that
|
|
285
|
+
* is not shaped like an environment variable name, or that carries a known
|
|
286
|
+
* secret prefix, is refused as a pasted credential — and the refusal quotes
|
|
287
|
+
* nothing, because a tool result is a transcript, a trace and usually an
|
|
288
|
+
* eval report.
|
|
289
|
+
*/
|
|
290
|
+
export declare function resolveSecret(envVar: string | undefined, what: string, env?: Record<string, string | undefined>): ResolvedSecret;
|
|
291
|
+
/** Header names that must never be supplied inline, and never survive a hop. */
|
|
292
|
+
export declare const CREDENTIAL_HEADERS: ReadonlySet<string>;
|
|
293
|
+
/**
|
|
294
|
+
* Refuse caller-supplied credential headers. The `auth` profile is the only
|
|
295
|
+
* way to attach one, which keeps the secret out of the tool arguments and
|
|
296
|
+
* therefore out of the transcript.
|
|
297
|
+
*/
|
|
298
|
+
export declare function rejectInlineCredentials(headers: Record<string, string>): string | null;
|
|
299
|
+
export type AppliedAuth = {
|
|
300
|
+
readonly ok: true;
|
|
301
|
+
readonly secretHeaders: ReadonlySet<string>;
|
|
302
|
+
readonly secrets: string[];
|
|
303
|
+
} | {
|
|
304
|
+
readonly ok: false;
|
|
305
|
+
readonly message: string;
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Apply an auth profile to `headers`. Returns the lowercased names of the
|
|
309
|
+
* headers it set — so a profile that puts the secret in `X-Api-Key` is
|
|
310
|
+
* guarded exactly as hard as one that puts it in `Authorization` — and the
|
|
311
|
+
* resolved secret values, which feed the redactor.
|
|
312
|
+
*/
|
|
313
|
+
export declare function applyAuth(headers: Record<string, string>, auth: AuthProfile | undefined, env?: Record<string, string | undefined>): AppliedAuth;
|
|
314
|
+
/**
|
|
315
|
+
* A function that scrubs secrets out of anything on its way back to the
|
|
316
|
+
* caller.
|
|
317
|
+
*
|
|
318
|
+
* Everything this package returns — results, refusals, the text of a
|
|
319
|
+
* provider error, an SMTP server's reply — goes through one of these. It is
|
|
320
|
+
* the backstop rather than the primary defence: a secret is only ever placed
|
|
321
|
+
* in a header or an SMTP AUTH line, never in a path or a body. But a
|
|
322
|
+
* provider that echoes a header, a transport error that quotes a request, or
|
|
323
|
+
* an SMTP server that repeats what it was sent would each put the secret in
|
|
324
|
+
* a transcript, and that is not a mistake worth leaving one layer deep.
|
|
325
|
+
*
|
|
326
|
+
* Secrets shorter than six characters are left alone: replacing every "x" in
|
|
327
|
+
* a result would mangle it without protecting anything real.
|
|
328
|
+
*/
|
|
329
|
+
export declare function redactorFor(secrets: readonly (string | undefined)[]): (text: string) => string;
|
|
330
|
+
export declare function responseHeaders(res: Response): Record<string, string>;
|
|
331
|
+
export type RawFetch = (req: Request, pinnedIp: string) => Promise<Response>;
|
|
332
|
+
export declare function _setRawFetch(fn: RawFetch | undefined): void;
|
|
333
|
+
export type OpenOptions = {
|
|
334
|
+
readonly url: URL;
|
|
335
|
+
readonly method: string;
|
|
336
|
+
readonly headers: Record<string, string>;
|
|
337
|
+
readonly body?: string | undefined;
|
|
338
|
+
readonly signal: AbortSignal;
|
|
339
|
+
readonly cfg: NotifyConfig;
|
|
340
|
+
/**
|
|
341
|
+
* `"refuse"` (the default for every send) reports a 3xx as a failure.
|
|
342
|
+
* `"follow"` is for read-only lookups and re-runs the whole gate per hop.
|
|
343
|
+
*/
|
|
344
|
+
readonly redirect?: "refuse" | "follow";
|
|
345
|
+
readonly maxRedirects?: number;
|
|
346
|
+
/** Lowercased header names to treat as credentials on this call. */
|
|
347
|
+
readonly credentialHeaders?: ReadonlySet<string>;
|
|
348
|
+
};
|
|
349
|
+
export type OpenResult = {
|
|
350
|
+
readonly res: Response;
|
|
351
|
+
readonly redirects: number;
|
|
352
|
+
/** True when a cross-origin hop dropped the credential headers. */
|
|
353
|
+
readonly credentialsDropped: boolean;
|
|
354
|
+
};
|
|
355
|
+
/**
|
|
356
|
+
* Issue a request. Redirects, when followed at all, are followed by hand so
|
|
357
|
+
* the allow-list, the SSRF gate and the credential rules run on every single
|
|
358
|
+
* hop. The response body is left unread — the caller decides how to drain it
|
|
359
|
+
* under which cap.
|
|
360
|
+
*/
|
|
361
|
+
export declare function openRequest(o: OpenOptions): Promise<OpenResult>;
|
|
362
|
+
export type CappedBody = {
|
|
363
|
+
readonly text: string;
|
|
364
|
+
readonly bytes: number;
|
|
365
|
+
/** True when the cap cut the read short; `text` is the prefix that fit. */
|
|
366
|
+
readonly truncated: boolean;
|
|
367
|
+
};
|
|
368
|
+
/**
|
|
369
|
+
* Drain a body with a hard byte cap, cancelling the stream the moment the cap
|
|
370
|
+
* is passed so a hostile endpoint cannot pin memory. The cap bounds what is
|
|
371
|
+
* ever held, not what is kept after buffering.
|
|
372
|
+
*/
|
|
373
|
+
export declare function readCapped(res: Response, maxBytes: number): Promise<CappedBody>;
|
|
374
|
+
/** A URL the caller supplied, or a readable refusal that does not echo it. */
|
|
375
|
+
export declare function parseUrl(raw: string): URL | string;
|
|
376
|
+
/** Read a dotted path out of a parsed JSON body. `undefined` when absent. */
|
|
377
|
+
export declare function readPath(value: unknown, path: string): unknown;
|
|
378
|
+
/**
|
|
379
|
+
* Turn anything thrown on the outbound path into the one-line string a tool
|
|
380
|
+
* returns. A refusal, a deadline and a transport failure read differently,
|
|
381
|
+
* because the caller's next move differs.
|
|
382
|
+
*/
|
|
383
|
+
export declare function describeFailure(err: unknown, deadline?: Deadline): string;
|
|
384
|
+
export type LedgerEntry = {
|
|
385
|
+
readonly tool: string;
|
|
386
|
+
readonly result: string;
|
|
387
|
+
};
|
|
388
|
+
export declare function ledgerLookup(tool: string, key: string | undefined): LedgerEntry | undefined;
|
|
389
|
+
export declare function ledgerRecord(tool: string, key: string | undefined, result: string): void;
|
|
390
|
+
/** Test-only — a fresh process's empty ledger. */
|
|
391
|
+
export declare function _resetIdempotencyLedger(): void;
|