decision-gate 0.1.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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +321 -0
- package/lib/budget.mjs +71 -0
- package/lib/cache.mjs +95 -0
- package/lib/config.mjs +113 -0
- package/lib/errors.mjs +27 -0
- package/lib/index.mjs +115 -0
- package/lib/ledger.mjs +73 -0
- package/lib/limits.mjs +101 -0
- package/lib/providers/index.mjs +8 -0
- package/lib/providers/typesafe.mjs +189 -0
- package/lib/providers/vercel-ai-gateway.mjs +20 -0
- package/lib/redaction.mjs +355 -0
- package/lib/state.mjs +146 -0
- package/package.json +45 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Adapted from herdr-find 4736dd5 (Apache-2.0)
|
|
2
|
+
import { APIError, RateLimitError, TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import { usdAt } from "../budget.mjs";
|
|
4
|
+
import { RequestSizeError, ServiceError, SpendCapError } from "../errors.mjs";
|
|
5
|
+
|
|
6
|
+
export const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
7
|
+
export const PINNED_MODEL = "jev-1.13.0";
|
|
8
|
+
// Output tokens are free for this model
|
|
9
|
+
export const JEV_PRICE = {
|
|
10
|
+
model: PINNED_MODEL,
|
|
11
|
+
usd_per_million_input_tokens: 0.042,
|
|
12
|
+
source: "https://docs.typesafe.ai/models.md",
|
|
13
|
+
checked: "2026-09-25"
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The documented context length has two budgets: the whole request, and the state plus its
|
|
17
|
+
// longest question, since the state is read once and each question is judged against it. Spend
|
|
18
|
+
// reserves the whole-request budget, not an empirical bytes/token ratio
|
|
19
|
+
export const MAX_INPUT_TOKENS = 65536;
|
|
20
|
+
export const MAX_STATE_QUESTION_TOKENS = 32768;
|
|
21
|
+
// For estimates shown before a run, the measured rate rather than the reservation
|
|
22
|
+
export const ESTIMATE_TOKENS_PER_BYTE = 0.25;
|
|
23
|
+
|
|
24
|
+
export const usdFor = (tokens) => usdAt(JEV_PRICE, tokens);
|
|
25
|
+
|
|
26
|
+
export const estimateUsd = (bytes) => usdFor(bytes * ESTIMATE_TOKENS_PER_BYTE);
|
|
27
|
+
|
|
28
|
+
const estimatedTokens = (value) => Buffer.byteLength(JSON.stringify(value) ?? "") * ESTIMATE_TOKENS_PER_BYTE;
|
|
29
|
+
|
|
30
|
+
// Refuses a request the model would reject for its length, by the same measured estimate shown
|
|
31
|
+
// before a run, so it fails here without being sent. The estimate is not exact, so a request close
|
|
32
|
+
// to either budget can still be rejected by the service
|
|
33
|
+
function checkSize(request) {
|
|
34
|
+
const questions = Object.values(request?.questions ?? {});
|
|
35
|
+
if (estimatedTokens(request) > MAX_INPUT_TOKENS) {
|
|
36
|
+
throw new RequestSizeError(`request is estimated above the model's ${MAX_INPUT_TOKENS}-token limit; nothing was sent`);
|
|
37
|
+
}
|
|
38
|
+
const longest = Math.max(0, ...questions.map(estimatedTokens));
|
|
39
|
+
if (estimatedTokens(request?.state) + longest > MAX_STATE_QUESTION_TOKENS) {
|
|
40
|
+
throw new RequestSizeError(`state plus its longest question is estimated above the model's ${MAX_STATE_QUESTION_TOKENS}-token limit; nothing was sent`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Only a loopback address may stand in for a provider's fixed endpoint, so a test's stand-in can
|
|
45
|
+
// never be a real host the key would be sent to
|
|
46
|
+
const destination = (fixed) => (env = process.env) => {
|
|
47
|
+
const wanted = env.DECISION_GATE_ENDPOINT;
|
|
48
|
+
if (!wanted) return fixed;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(wanted);
|
|
51
|
+
if (url.protocol === "http:" && !url.username && !url.password && ["127.0.0.1", "[::1]"].includes(url.hostname)) return url.href;
|
|
52
|
+
} catch { /* Report a generic error without echoing the supplied URL */ }
|
|
53
|
+
throw new ServiceError("DECISION_GATE_ENDPOINT must be a numeric HTTP loopback URL");
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The request and response shapes are TypeSafe's own, so any provider serving that API at a fixed
|
|
57
|
+
// endpoint shares this responder; the gate, not the caller, names the model on the wire
|
|
58
|
+
const createResponder = ({ label, wireModel, maxRetries: providerRetries, pauseFallbackMs }) => function respond({ key, budget, assertSafe, limiter, book = async () => {}, fetchImpl = globalThis.fetch, endpoint, timeoutMs = 30000, maxRetries = providerRetries }) {
|
|
59
|
+
if (!key?.authorization || !budget || !assertSafe || !endpoint) throw new TypeError("key, spend budget, never-send check and endpoint are required");
|
|
60
|
+
return async (request, { signal } = {}) => {
|
|
61
|
+
if (![undefined, PINNED_MODEL].includes(request.model)) throw new ServiceError(`request model must be omitted or ${PINNED_MODEL}`);
|
|
62
|
+
request = { ...request, model: wireModel };
|
|
63
|
+
const abort = new AbortController();
|
|
64
|
+
let localError;
|
|
65
|
+
const local = async (fn) => {
|
|
66
|
+
try { return await fn(); }
|
|
67
|
+
catch (error) { localError = error; abort.abort(); throw error; }
|
|
68
|
+
};
|
|
69
|
+
// Usage measures about a quarter token per request byte, so counting bytes overcounts
|
|
70
|
+
// without throttling like the full-context spend reservation would
|
|
71
|
+
const counted = (bytes) => Math.min(bytes, MAX_INPUT_TOKENS);
|
|
72
|
+
let first, slot, sent = false;
|
|
73
|
+
const client = new TypeSafeClient({
|
|
74
|
+
apiKey: key.authorization.slice("Bearer ".length),
|
|
75
|
+
baseURL: new URL(endpoint).origin,
|
|
76
|
+
defaultModel: wireModel,
|
|
77
|
+
// Explicit settings prevent SDK environment defaults from leaking data or keys
|
|
78
|
+
logLevel: "off",
|
|
79
|
+
timeout: timeoutMs,
|
|
80
|
+
retry: { maxRetries, respectRetryAfter: true },
|
|
81
|
+
fetch: async (_url, init) => {
|
|
82
|
+
await local(() => assertSafe(init.body));
|
|
83
|
+
if (sent) {
|
|
84
|
+
// A retry's wait for the account's window runs inside its timer, so an abort there is
|
|
85
|
+
// left to the SDK as a retryable timeout, not a local failure that ends the ask
|
|
86
|
+
try { await slot?.again(counted(Buffer.byteLength(init.body)), { signal: init.signal }); }
|
|
87
|
+
catch (error) { if (init.signal.aborted) throw error; await local(() => { throw error; }); }
|
|
88
|
+
}
|
|
89
|
+
sent = true;
|
|
90
|
+
let ticket = first;
|
|
91
|
+
first = undefined;
|
|
92
|
+
// A retry after an attempt that may have been billed cannot wait for spend inside its
|
|
93
|
+
// timer, so one that does not fit now fails this request rather than the ceiling
|
|
94
|
+
if (!ticket) {
|
|
95
|
+
ticket = await local(async () => {
|
|
96
|
+
let held;
|
|
97
|
+
try { held = budget.reserve(MAX_INPUT_TOKENS); }
|
|
98
|
+
catch (error) { throw error instanceof SpendCapError ? new ServiceError(`${label} request failed and its retry does not fit under the spend ceiling now`) : error; }
|
|
99
|
+
try { await book(); } catch (error) { held.release(); throw error; }
|
|
100
|
+
return held;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
// Surface redirects as non-retryable HTTP errors without following them
|
|
106
|
+
response = await fetchImpl(endpoint, { ...init, redirect: "manual" });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await local(async () => { ticket.settle(null); await book(); });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
await local(async () => {
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
if (response.status >= 500 || response.status === 408) ticket.settle(null);
|
|
114
|
+
// An unbilled refusal keeps its reservation for the retry, so the retry needs no new room
|
|
115
|
+
else first = ticket;
|
|
116
|
+
} else {
|
|
117
|
+
let json;
|
|
118
|
+
try { json = await response.clone().json(); }
|
|
119
|
+
catch { /* An unreadable response may still have been billed */ }
|
|
120
|
+
const tokens = json?.usage?.input_tokens;
|
|
121
|
+
ticket.settle(Number.isInteger(tokens) && tokens <= MAX_INPUT_TOKENS ? tokens : null);
|
|
122
|
+
}
|
|
123
|
+
await book();
|
|
124
|
+
// The slot is held until the whole call settles, so no waiter starts ahead of this pause
|
|
125
|
+
if (response.status === 429 || response.status === 529) {
|
|
126
|
+
const delay = new RateLimitError(response.status, undefined, response.headers).retryAfterMs;
|
|
127
|
+
await limiter?.pause(Number.isFinite(delay) ? delay : pauseFallbackMs);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const bytes = Buffer.byteLength(JSON.stringify(request));
|
|
134
|
+
// The first attempt's reservation and the account slot are taken before the SDK starts its
|
|
135
|
+
// attempt timer, so waiting for either never uses up or aborts the attempt
|
|
136
|
+
first = await budget.acquire(MAX_INPUT_TOKENS, signal);
|
|
137
|
+
try {
|
|
138
|
+
slot = await limiter?.take(counted(bytes), { estimated: bytes * ESTIMATE_TOKENS_PER_BYTE, signal });
|
|
139
|
+
await book();
|
|
140
|
+
} catch (error) { first.release(); await slot?.release(); throw error; }
|
|
141
|
+
let json;
|
|
142
|
+
try {
|
|
143
|
+
json = await client.systemOne(request, { signal: signal ? AbortSignal.any([signal, abort.signal]) : abort.signal });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (signal?.aborted) throw signal.reason;
|
|
146
|
+
if (localError) throw localError;
|
|
147
|
+
if (error instanceof APIError) {
|
|
148
|
+
if (error.status === 429 || error.status === 529) {
|
|
149
|
+
// The SDK exposes its Retry-After parser through RateLimitError, including HTTP dates
|
|
150
|
+
const delay = new RateLimitError(error.status, undefined, error.headers).retryAfterMs;
|
|
151
|
+
const after = Number.isFinite(delay) ? `${Math.ceil(delay / 1000)} seconds` : "not supplied";
|
|
152
|
+
throw new ServiceError(`${label} returned HTTP ${error.status}; stopped; retry-after: ${after}`, { status: error.status });
|
|
153
|
+
}
|
|
154
|
+
throw new ServiceError(`${label} returned HTTP ${error.status}`, { status: error.status });
|
|
155
|
+
}
|
|
156
|
+
throw new ServiceError(`${label} request failed; no input or key logged`);
|
|
157
|
+
} finally {
|
|
158
|
+
await slot?.release();
|
|
159
|
+
// A reservation no attempt used holds no spend
|
|
160
|
+
if (first) { first.release(); await book(); }
|
|
161
|
+
}
|
|
162
|
+
if (json?.model !== wireModel) throw new ServiceError(`${label} answered with an unexpected model`);
|
|
163
|
+
return json;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// A provider serving TypeSafe's API: its key variable, one hard-coded destination, the model id it
|
|
168
|
+
// sends and expects back, its price, and limiter defaults for an account there
|
|
169
|
+
export function systemOneProvider({ name, label, keyEnv, endpoint, wireModel, pinned, price, limits, maxRetries = 2, pauseFallbackMs = 1000 }) {
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
name, label, keyEnv, model: wireModel, pinned, price,
|
|
172
|
+
limits: Object.freeze(limits),
|
|
173
|
+
endpoint: destination(endpoint),
|
|
174
|
+
checkSize,
|
|
175
|
+
respond: createResponder({ label, wireModel, maxRetries, pauseFallbackMs })
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const typesafe = systemOneProvider({
|
|
180
|
+
name: "typesafe",
|
|
181
|
+
label: "TypeSafe",
|
|
182
|
+
keyEnv: "TYPESAFE_API_KEY",
|
|
183
|
+
endpoint: JEV_ENDPOINT,
|
|
184
|
+
wireModel: PINNED_MODEL,
|
|
185
|
+
pinned: true,
|
|
186
|
+
price: JEV_PRICE,
|
|
187
|
+
// TypeSafe's published limits for the pinned model
|
|
188
|
+
limits: { requestsPerMinute: 1200, tokensPerSecond: 250000, inFlight: 4 }
|
|
189
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { JEV_PRICE, systemOneProvider } from "./typesafe.mjs";
|
|
2
|
+
|
|
3
|
+
// Experimental and unpinned: the gate sends this floating id, so the Jev version behind it can change
|
|
4
|
+
export const GATEWAY_MODEL = "typesafe-ai/jev";
|
|
5
|
+
|
|
6
|
+
export const vercelAiGateway = systemOneProvider({
|
|
7
|
+
name: "vercel-ai-gateway",
|
|
8
|
+
label: "Vercel AI Gateway",
|
|
9
|
+
keyEnv: "AI_GATEWAY_API_KEY",
|
|
10
|
+
// The one endpoint the gate posts to, with TypeSafe's request and response shapes
|
|
11
|
+
endpoint: "https://ai-gateway.vercel.sh/typesafe/v1/systemone",
|
|
12
|
+
wireModel: GATEWAY_MODEL,
|
|
13
|
+
pinned: false,
|
|
14
|
+
// Booked at TypeSafe's list price
|
|
15
|
+
price: { ...JEV_PRICE, model: GATEWAY_MODEL, source: "https://vercel.com/ai-gateway/models/jev", checked: "2026-09-26" },
|
|
16
|
+
// Conservative guesses rather than measurements
|
|
17
|
+
limits: { requestsPerMinute: 60, tokensPerSecond: 250000, inFlight: 2 },
|
|
18
|
+
maxRetries: 1,
|
|
19
|
+
pauseFallbackMs: 5000
|
|
20
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { RedactionError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
// Adapted from herdr-find 4736dd5 (Apache-2.0)
|
|
6
|
+
|
|
7
|
+
// Redaction for everything a caller sends, in two layers: secret shapes built in here,
|
|
8
|
+
// then the user's own value list (names, customer ids, domains no pattern can know). A fail-closed
|
|
9
|
+
// sweep follows: a request in which any forbidden pattern survives is never sent. No rule, pattern
|
|
10
|
+
// or match is ever printed; a report names rule classes and counts, nothing else.
|
|
11
|
+
//
|
|
12
|
+
// The optional value list format:
|
|
13
|
+
//
|
|
14
|
+
// { "rules": [[class, pattern, replacement], ...], "forbidden": [pattern, ...] }
|
|
15
|
+
//
|
|
16
|
+
// Patterns are Python-flavoured regular expressions; a leading inline flag group such as (?i)
|
|
17
|
+
// becomes a JavaScript flag. Replacements use Python's \1 and \g<name> group syntax.
|
|
18
|
+
|
|
19
|
+
// A secret word as a whole segment of a key, split by _ or - or a case change, so SECRET_KEY_BASE,
|
|
20
|
+
// apiKey, DBPassword and password_confirmation qualify and tokens, monkey and keyboard do not
|
|
21
|
+
const SECRET_SEGMENT = String.raw`(?:(?<![A-Za-z0-9])(?:password|passwd|pwd|secret|token|apikey|key)(?![a-z])|(?:Password|Passwd|Pwd|Secret|Token|Apikey|Key)(?![a-z])|(?<![A-Za-z0-9])(?:PASSWORD|PASSWD|PWD|SECRET|TOKEN|APIKEY|KEY)(?![A-Za-z]))`;
|
|
22
|
+
|
|
23
|
+
const SECRET_PAIR = new RegExp(String.raw`(?<![A-Za-z0-9_-])[A-Za-z0-9_-]+(?![A-Za-z0-9_-])(?<=${SECRET_SEGMENT}[A-Za-z0-9_-]*)["']?\s*(?:===|!==|==|!=|=>|:=|=|:(?!:))\s*`, "g");
|
|
24
|
+
const QUOTES = "\"'`";
|
|
25
|
+
const CLOSES = { ")": "(", "]": "[", "}": "{" };
|
|
26
|
+
|
|
27
|
+
// Plainly code: a bare identifier with no digits, a $VAR or ${VAR} reference, member access or
|
|
28
|
+
// indexing, or a call. Keyword-led expressions such as await x or new X() stop at the space and
|
|
29
|
+
// arrive here as a bare identifier
|
|
30
|
+
const PLAINLY_CODE = /^(?:[A-Za-z_]+|\$\{*\s*[A-Za-z_]*\s*\}*|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]*\])+|[^(]*\(.*)$/;
|
|
31
|
+
|
|
32
|
+
// Brackets and quotes left open earlier on the current line, which a value may close; read once
|
|
33
|
+
// per line as the scan moves forward
|
|
34
|
+
function lineContext(text) {
|
|
35
|
+
let at = 0;
|
|
36
|
+
let open = [];
|
|
37
|
+
let quotes = new Set();
|
|
38
|
+
return (to) => {
|
|
39
|
+
for (; at < to; at += 1) {
|
|
40
|
+
const c = text[at];
|
|
41
|
+
if (c === "\n") { open = []; quotes = new Set(); }
|
|
42
|
+
else if (QUOTES.includes(c)) quotes.has(c) ? quotes.delete(c) : quotes.add(c);
|
|
43
|
+
else if ("([{".includes(c)) open.push(c);
|
|
44
|
+
else if (c in CLOSES && open.at(-1) === CLOSES[c]) open.pop();
|
|
45
|
+
}
|
|
46
|
+
return { open, quotes };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The value of a key that names a secret is redacted unless it is plainly code. A quoted value
|
|
51
|
+
// runs to its closing quote, honouring escapes, or to the end of the line. A bare value ends at
|
|
52
|
+
// whitespace, a comma, or a quote or bracket that closes one opened earlier on the line; a stray
|
|
53
|
+
// quote or bracket stays inside it
|
|
54
|
+
function redactPairs(text, counted) {
|
|
55
|
+
let out = "";
|
|
56
|
+
let last = 0;
|
|
57
|
+
const context = lineContext(text);
|
|
58
|
+
SECRET_PAIR.lastIndex = 0;
|
|
59
|
+
for (let match; (match = SECRET_PAIR.exec(text));) {
|
|
60
|
+
const start = match.index + match[0].length;
|
|
61
|
+
const lineEnd = text.indexOf("\n", start) === -1 ? text.length : text.indexOf("\n", start);
|
|
62
|
+
let end = start;
|
|
63
|
+
let value;
|
|
64
|
+
if (QUOTES.includes(text[start])) {
|
|
65
|
+
const quote = text[start];
|
|
66
|
+
end += 1;
|
|
67
|
+
while (end < lineEnd && text[end] !== quote) end += text[end] === "\\" ? 2 : 1;
|
|
68
|
+
end = Math.min(end + 1, lineEnd);
|
|
69
|
+
value = `${quote}[redacted]${quote}`;
|
|
70
|
+
} else {
|
|
71
|
+
const before = context(start);
|
|
72
|
+
const inner = [];
|
|
73
|
+
for (; end < lineEnd; end += 1) {
|
|
74
|
+
const c = text[end];
|
|
75
|
+
if (/\s/.test(c) || c === ",") break;
|
|
76
|
+
if (QUOTES.includes(c) && before.quotes.has(c)) break;
|
|
77
|
+
if ("([{".includes(c)) inner.push(c);
|
|
78
|
+
else if (c in CLOSES) {
|
|
79
|
+
if (inner.at(-1) === CLOSES[c]) inner.pop();
|
|
80
|
+
else if (before.open.includes(CLOSES[c])) break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const bare = text.slice(start, end).replace(/;+$/, "");
|
|
84
|
+
value = !bare || PLAINLY_CODE.test(bare) ? text.slice(start, end) : `[redacted]${text.slice(start + bare.length, end)}`;
|
|
85
|
+
}
|
|
86
|
+
if (value !== text.slice(start, end)) counted();
|
|
87
|
+
out += text.slice(last, start) + value;
|
|
88
|
+
last = end;
|
|
89
|
+
SECRET_PAIR.lastIndex = Math.max(end, start);
|
|
90
|
+
}
|
|
91
|
+
return out + text.slice(last);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// An authorization or auth key, as a header, a JSON or YAML key, or a query or shell parameter.
|
|
95
|
+
// Prose such as "work authorization: F-1 OPT" also has one, so its value decides
|
|
96
|
+
const AUTH_KEY = /(?<![A-Za-z0-9])(?:proxy-)?auth(?:orization)?["'`]?\s*[:=]+\s*/gi;
|
|
97
|
+
// HTTP authentication schemes, registered and common vendor ones
|
|
98
|
+
const AUTH_SCHEME = /^(?:basic|bearer|token|digest|negotiate|ntlm|hoba|mutual|vapid|scram-sha-(?:1|256)|aws4-hmac-sha256|dpop|gnap|oauth|hawk|signature|apikey|api-key|sso-key|key)[ \t]+/i;
|
|
99
|
+
// Several schemes are also English words, so what follows decides: auth-params such as Digest's,
|
|
100
|
+
// a placeholder an earlier rule wrote, or a token68 run that is not a plain word such as "required".
|
|
101
|
+
// A single letter counts, as in "Bearer x"
|
|
102
|
+
function schemeCredential(value) {
|
|
103
|
+
const scheme = AUTH_SCHEME.exec(value);
|
|
104
|
+
if (!scheme) return false;
|
|
105
|
+
const rest = value.slice(scheme[0].length);
|
|
106
|
+
if (/^[A-Za-z][\w-]*\s*=/.test(rest) || /^\[[a-z ]+\]/.test(rest)) return true;
|
|
107
|
+
const run = /^[A-Za-z0-9._~+/-]+=*/.exec(rest)?.[0];
|
|
108
|
+
return Boolean(run) && !/^[A-Za-z][a-z]+$/.test(run);
|
|
109
|
+
}
|
|
110
|
+
// A long run with no spaces holding a letter and a digit, as keys and hex digests do, or a base64
|
|
111
|
+
// marker; hyphen- or slash-joined words such as "OPT-STEM-Extension" are not tokens
|
|
112
|
+
const TOKEN_CHARS = /^[A-Za-z0-9._~+/=-]{16,}$/;
|
|
113
|
+
const tokenShaped = (value) => TOKEN_CHARS.test(value) && /[A-Za-z]/.test(value) && /[0-9+]|=$/.test(value);
|
|
114
|
+
|
|
115
|
+
// The value after an authorization or auth key is redacted when it is a scheme followed by a
|
|
116
|
+
// credential, which may carry parameters, so it runs to the end of its quotes or line, or when it
|
|
117
|
+
// is token-shaped on its own. Anything else, such as a visa status or a sentence, is kept
|
|
118
|
+
function redactAuthorization(text, counted) {
|
|
119
|
+
let out = "";
|
|
120
|
+
let last = 0;
|
|
121
|
+
const context = lineContext(text);
|
|
122
|
+
AUTH_KEY.lastIndex = 0;
|
|
123
|
+
for (let match; (match = AUTH_KEY.exec(text));) {
|
|
124
|
+
const start = match.index + match[0].length;
|
|
125
|
+
const lineEnd = text.slice(start).search(/[\r\n]/);
|
|
126
|
+
const stop = lineEnd === -1 ? text.length : start + lineEnd;
|
|
127
|
+
let end = start;
|
|
128
|
+
let value;
|
|
129
|
+
if (QUOTES.includes(text[start])) {
|
|
130
|
+
const quote = text[start];
|
|
131
|
+
let close = start + 1;
|
|
132
|
+
while (close < stop && text[close] !== quote) close += text[close] === "\\" ? 2 : 1;
|
|
133
|
+
const inner = text.slice(start + 1, Math.min(close, stop));
|
|
134
|
+
if (schemeCredential(inner) || tokenShaped(inner)) {
|
|
135
|
+
end = Math.min(close + 1, stop);
|
|
136
|
+
value = `${quote}[redacted]${quote}`;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// A quote opened earlier on the line, as around a curl -H header, ends the value
|
|
140
|
+
const { quotes } = context(start);
|
|
141
|
+
const closing = (c) => QUOTES.includes(c) && quotes.has(c);
|
|
142
|
+
if (schemeCredential(text.slice(start, stop))) {
|
|
143
|
+
end = start;
|
|
144
|
+
while (end < stop && !closing(text[end])) end += text[end] === "\\" ? 2 : 1;
|
|
145
|
+
end = Math.min(end, stop);
|
|
146
|
+
value = "[redacted]";
|
|
147
|
+
} else {
|
|
148
|
+
end = start;
|
|
149
|
+
while (end < stop && !/[\s,;&)\]}]/.test(text[end]) && !QUOTES.includes(text[end])) end += 1;
|
|
150
|
+
value = tokenShaped(text.slice(start, end)) ? "[redacted]" : undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (value === undefined) continue;
|
|
154
|
+
counted();
|
|
155
|
+
out += text.slice(last, start) + value;
|
|
156
|
+
last = end;
|
|
157
|
+
AUTH_KEY.lastIndex = end;
|
|
158
|
+
}
|
|
159
|
+
return out + text.slice(last);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Known secret formats only, applied before the user's rules to whole texts. A secret in no listed
|
|
163
|
+
// format and not on the user's list is sent; guessing at random-looking strings erased file paths.
|
|
164
|
+
export const BUILT_IN_RULES = [
|
|
165
|
+
["private-key", /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|$)/g, "[private key]"],
|
|
166
|
+
// Runs before the prefixed formats so a variable holding one loses its whole value
|
|
167
|
+
["secret-pair", redactPairs],
|
|
168
|
+
["jwt", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[token]"],
|
|
169
|
+
["bearer", /\b(bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [token]"],
|
|
170
|
+
["authorization", redactAuthorization],
|
|
171
|
+
// OpenAI and Anthropic: a known label with any base64url body, or any body with a capital and a
|
|
172
|
+
// digit; kebab-case names such as sk-learn-model-v2.py are lowercase and pass
|
|
173
|
+
["api-key", /\bsk-(?:(?:proj|svcacct|admin|None|ant-[a-z]+[0-9]*)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,})/g, "[api key]"],
|
|
174
|
+
["api-key", /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g, "[api key]"],
|
|
175
|
+
["github-token", /\b(?:gh[oprsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, "[github token]"],
|
|
176
|
+
["gitlab-token", /\bglpat-[A-Za-z0-9_-]{20,}/g, "[gitlab token]"],
|
|
177
|
+
["npm-token", /\bnpm_[A-Za-z0-9]{36}/g, "[npm token]"],
|
|
178
|
+
["slack-token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, "[slack token]"],
|
|
179
|
+
["aws-key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}/g, "[aws key]"],
|
|
180
|
+
["google-key", /\bAIza[0-9A-Za-z_-]{35}/g, "[google key]"],
|
|
181
|
+
["url-credentials", /\b([a-z][a-z0-9+.-]*:\/\/)[^\s:/@]+:[^\s@/]+@/gi, "$1[redacted]@"],
|
|
182
|
+
// Pixel-density image names such as logo@2x.png are not addresses
|
|
183
|
+
["email", /\b[A-Za-z0-9._%+-]+@(?![0-9]+(?:\.[0-9]+)?[xX]\.(?:[pP][nN][gG]|[jJ][pP][eE]?[gG]|[gG][iI][fF]|[wW][eE][bB][pP]|[sS][vV][gG]|[aA][vV][iI][fF])\b)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g, "[email]"]
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
// A private key piped in as several lines, judged by the key text at the end of each line so any
|
|
187
|
+
// prefix (rg paths, diff markers, line numbers, or a quote opening a multi-line value) is ignored
|
|
188
|
+
// and kept. A block opens on a line whose last text is the BEGIN marker, so a one-line string
|
|
189
|
+
// constant holding the marker never opens one. It runs while lines end with key body, to a line
|
|
190
|
+
// holding the END marker, whatever code closes the value after it, or, when the key was cut off,
|
|
191
|
+
// to the first line that is not body
|
|
192
|
+
const BEGIN_AT_END = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----\s*$/;
|
|
193
|
+
const END_AT_END = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----.*$/;
|
|
194
|
+
const HEADER_AT_END = /(?<![A-Za-z-])(?:Proc-Type|DEK-Info|Version|Comment|Hash|Charset): [^\n]*$/;
|
|
195
|
+
const BODY_AT_END = /(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]{40,}\s*$/;
|
|
196
|
+
// A blank line, perhaps under a prefix such as rg's path:3: or cat -n's number, as PGP armour has
|
|
197
|
+
const BLANK_AT_END = /(?:^|[:-]\d+[:-]|^\s*\d+|^[+-])\s*$/;
|
|
198
|
+
// The last body line and a PGP checksum line are short; they count only in a short run ending at END
|
|
199
|
+
const TAIL_AT_END = /(?<![A-Za-z0-9+/=])[A-Za-z0-9+/=]+\s*$/;
|
|
200
|
+
|
|
201
|
+
// A PGP block ends with the last short body line, then its checksum line, then END
|
|
202
|
+
function tailBeforeEnd(lines, i) {
|
|
203
|
+
for (let j = i + 1; j < lines.length && j <= i + 3; j += 1) {
|
|
204
|
+
if (END_AT_END.test(lines[j])) return true;
|
|
205
|
+
if (!TAIL_AT_END.test(lines[j])) return false;
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function keyText(line, match) {
|
|
211
|
+
if (!match) return undefined;
|
|
212
|
+
// A diff's leading + is a prefix, although + is also a base64 character
|
|
213
|
+
const at = match.index === 0 && line[0] === "+" ? 1 : match.index;
|
|
214
|
+
return `${line.slice(0, at)}[private key]`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Maps each line of a piped key block to its redacted form
|
|
218
|
+
export function privateKeyLines(lines) {
|
|
219
|
+
const redacted = new Map();
|
|
220
|
+
let open = false;
|
|
221
|
+
lines.forEach((line, i) => {
|
|
222
|
+
if (open) {
|
|
223
|
+
const end = line.match(END_AT_END);
|
|
224
|
+
const body = line.match(HEADER_AT_END) ?? line.match(BODY_AT_END)
|
|
225
|
+
?? (tailBeforeEnd(lines, i) ? line.match(TAIL_AT_END) : null);
|
|
226
|
+
if (end || body) {
|
|
227
|
+
redacted.set(line, keyText(line, end ?? body));
|
|
228
|
+
open = !end;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (BLANK_AT_END.test(line)) return;
|
|
232
|
+
}
|
|
233
|
+
const begin = line.match(BEGIN_AT_END);
|
|
234
|
+
open = Boolean(begin);
|
|
235
|
+
if (begin) redacted.set(line, keyText(line, begin));
|
|
236
|
+
});
|
|
237
|
+
return redacted;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Shapes that must never survive redaction, whatever the user's list says
|
|
241
|
+
export const BUILT_IN_FORBIDDEN = [
|
|
242
|
+
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/,
|
|
243
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/,
|
|
244
|
+
/\bsk-(?:(?:proj|svcacct|admin|None|ant-[a-z]+[0-9]*)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,})/,
|
|
245
|
+
/\b(?:gh[oprsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/,
|
|
246
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/,
|
|
247
|
+
/\bnpm_[A-Za-z0-9]{36}/,
|
|
248
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/,
|
|
249
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}/,
|
|
250
|
+
/\bAIza[0-9A-Za-z_-]{35}/
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
function compile(pattern, extra = "") {
|
|
254
|
+
if (typeof pattern !== "string") throw new RedactionError("redaction patterns must be strings");
|
|
255
|
+
const match = pattern.match(/^\(\?([aimsux]+)\)/);
|
|
256
|
+
const flags = new Set(extra);
|
|
257
|
+
let source = pattern;
|
|
258
|
+
if (match) {
|
|
259
|
+
source = pattern.slice(match[0].length);
|
|
260
|
+
for (const flag of match[1]) {
|
|
261
|
+
if (flag === "i" || flag === "m" || flag === "s") flags.add(flag);
|
|
262
|
+
else if (flag !== "u" && flag !== "a") throw new RedactionError("a redaction pattern uses an unsupported inline flag");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Unicode mode where the pattern allows it, since Python matches by code point; a pattern with
|
|
266
|
+
// an escape unicode mode rejects (such as \-) compiles without it
|
|
267
|
+
for (const mode of ["u", ""]) {
|
|
268
|
+
try {
|
|
269
|
+
return new RegExp(source, [...flags].join("") + mode);
|
|
270
|
+
} catch { /* try the next mode */ }
|
|
271
|
+
}
|
|
272
|
+
// The pattern itself is private, so the error does not quote it
|
|
273
|
+
throw new RedactionError("a redaction pattern does not compile as a JavaScript regular expression");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Python's replacement syntax: \1, \g<1> and \g<name> insert a group, \\ a backslash; nothing
|
|
277
|
+
// else is special, and a JavaScript $ stays literal
|
|
278
|
+
function expand(replacement, match) {
|
|
279
|
+
const named = typeof match[match.length - 1] === "object" ? match[match.length - 1] : {};
|
|
280
|
+
return replacement.replace(/\\(?:g<(\w+)>|(\d{1,2})|(\\))/g, (_all, name, number, slash) => {
|
|
281
|
+
if (slash) return "\\";
|
|
282
|
+
const key = name ?? number;
|
|
283
|
+
const value = /^\d+$/.test(key) ? match[Number(key)] : named?.[key];
|
|
284
|
+
return value ?? "";
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function createRedactor(spec = { rules: [], forbidden: [] }) {
|
|
289
|
+
if (!spec || !Array.isArray(spec.rules) || !Array.isArray(spec.forbidden)) throw new RedactionError("the redaction file must hold rules and forbidden arrays");
|
|
290
|
+
const rules = spec.rules.map((rule, index) => {
|
|
291
|
+
if (!Array.isArray(rule) || rule.length !== 3 || rule.some((part) => typeof part !== "string")) throw new RedactionError(`redaction rule ${index + 1} must be [class, pattern, replacement]`);
|
|
292
|
+
return { cls: rule[0], pattern: compile(rule[1], "g"), replacement: rule[2] };
|
|
293
|
+
});
|
|
294
|
+
const forbidden = [...BUILT_IN_FORBIDDEN, ...spec.forbidden.map((pattern) => compile(pattern))];
|
|
295
|
+
const counts = Object.create(null);
|
|
296
|
+
const count = (cls) => { counts[cls] = (counts[cls] ?? 0) + 1; };
|
|
297
|
+
const redact = (text) => {
|
|
298
|
+
let out = text;
|
|
299
|
+
for (const [cls, pattern, replacement] of BUILT_IN_RULES) {
|
|
300
|
+
if (typeof pattern === "function") { out = pattern(out, () => count(cls)); continue; }
|
|
301
|
+
out = out.replace(pattern, (...match) => { count(cls); return replacement.replace(/\$(\d)/g, (_all, n) => match[Number(n)] ?? ""); });
|
|
302
|
+
}
|
|
303
|
+
for (const rule of rules) {
|
|
304
|
+
out = out.replace(rule.pattern, (...match) => { count(rule.cls); return expand(rule.replacement, match); });
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
};
|
|
308
|
+
const allowed = (text) => forbidden.every((pattern) => !pattern.test(text));
|
|
309
|
+
// True when nothing forbidden survives and every built-in rule is already applied. Rule
|
|
310
|
+
// fixpoints only hold for decoded text: on serialized JSON a placeholder such as
|
|
311
|
+
// "authorization: [redacted]" runs into the next field and would never look redacted
|
|
312
|
+
const clean = (text) => allowed(text) && BUILT_IN_RULES.every(([, pattern, replacement]) => {
|
|
313
|
+
if (typeof pattern === "function") return pattern(text, () => {}) === text;
|
|
314
|
+
pattern.lastIndex = 0;
|
|
315
|
+
return text.replace(pattern, replacement) === text;
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
redact,
|
|
319
|
+
allowed,
|
|
320
|
+
clean,
|
|
321
|
+
// The fail-closed check on a serialized request: every decoded key and string must be clean,
|
|
322
|
+
// and the exact bytes must hold nothing forbidden
|
|
323
|
+
check(body) {
|
|
324
|
+
const decoded = (value) => {
|
|
325
|
+
if (typeof value === "string") return clean(value);
|
|
326
|
+
if (value === null || typeof value !== "object") return true;
|
|
327
|
+
return Object.entries(value).every(([key, child]) => clean(key) && decoded(child));
|
|
328
|
+
};
|
|
329
|
+
let request;
|
|
330
|
+
try { request = JSON.parse(body); }
|
|
331
|
+
catch { throw new RedactionError("request must serialize as JSON; nothing was sent"); }
|
|
332
|
+
if (!decoded(request) || !allowed(body)) throw new RedactionError("never-send check refused a request; nothing was sent");
|
|
333
|
+
},
|
|
334
|
+
fingerprint: createHash("sha256").update(JSON.stringify(spec)).digest("hex"),
|
|
335
|
+
counts: () => ({ ...counts }),
|
|
336
|
+
size: { rules: BUILT_IN_RULES.length + rules.length, forbidden: forbidden.length }
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function loadRedactor(path) {
|
|
341
|
+
let stat;
|
|
342
|
+
try {
|
|
343
|
+
stat = statSync(path);
|
|
344
|
+
} catch {
|
|
345
|
+
throw new RedactionError("cannot read the configured redaction file");
|
|
346
|
+
}
|
|
347
|
+
if (!stat.isFile() || (stat.mode & 0o077) !== 0) throw new RedactionError("the redaction file must be a private regular file (chmod 600)");
|
|
348
|
+
let spec;
|
|
349
|
+
try {
|
|
350
|
+
spec = JSON.parse(readFileSync(path, "utf8"));
|
|
351
|
+
} catch {
|
|
352
|
+
throw new RedactionError("the redaction file is not valid JSON");
|
|
353
|
+
}
|
|
354
|
+
return createRedactor(spec);
|
|
355
|
+
}
|