midline-agent 0.1.9 → 0.2.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/src/proxy.ts ADDED
@@ -0,0 +1,435 @@
1
+ import * as http from "http";
2
+ import * as https from "https";
3
+ import { AddressInfo } from "net";
4
+ import { MidlineAgent } from "./agent";
5
+ import { ConfigError, env, envInt, loadCa, resolveCapture, trustStore } from "./config";
6
+ import { createRequestContext, RequestContext } from "./context";
7
+ import { BodyTap, headerValue, isCompressed } from "./tap";
8
+ import { CaInput, CaptureOptions, MidlineConfig } from "./types";
9
+
10
+ export interface MidlineProxyOptions {
11
+ /**
12
+ * The destination API — the thing being monitored. `http://localhost:4000`,
13
+ * `https://staging.example.com`, `https://api.example.com`. Only its origin and
14
+ * path prefix are used; clients can never steer a request to another host.
15
+ * Env fallback: `TARGET_API_URL`.
16
+ */
17
+ target?: string;
18
+ /**
19
+ * Extra CAs to trust for the destination only (a private/internal CA). Added to
20
+ * the default trust store; verification is never switched off. Independent of
21
+ * the Midline server's `ca`. Env fallback: `TARGET_API_CA` (PEM or file path).
22
+ */
23
+ targetCa?: CaInput;
24
+ /** Time allowed for the destination to start responding, in ms. Default 30000. Env: `TARGET_API_TIMEOUT_MS`. */
25
+ timeoutMs?: number;
26
+ /** Time allowed to connect to the destination, in ms. Default 5000. */
27
+ connectTimeoutMs?: number;
28
+ /**
29
+ * Retries for requests that never reached the destination (refused, reset, DNS,
30
+ * connect timeout). Only GET, HEAD and OPTIONS without a body are retried — a
31
+ * write might have been applied. Default 0. Env: `TARGET_API_RETRIES`.
32
+ */
33
+ retries?: number;
34
+ /** Requests with a larger body get 413. Default 10 MiB. */
35
+ maxRequestBodyBytes?: number;
36
+ /** Send the client's Host header upstream instead of the destination's. Default false. */
37
+ preserveHost?: boolean;
38
+ /**
39
+ * Trust `X-Forwarded-*` from the client, e.g. when this proxy itself sits behind a
40
+ * load balancer. Default false, so clients cannot spoof their address.
41
+ */
42
+ trustForwardedHeaders?: boolean;
43
+ /** Overrides the agent's capture settings for proxied traffic. */
44
+ capture?: CaptureOptions;
45
+ /** Where events go. Defaults to `MidlineAgent.current`. Traffic is forwarded either way. */
46
+ agent?: MidlineAgent;
47
+ /** Receives proxy diagnostics. Defaults to console.warn. */
48
+ onError?: (message: string) => void;
49
+ }
50
+
51
+ export interface MidlineProxy {
52
+ (req: http.IncomingMessage, res: http.ServerResponse): void;
53
+ readonly target: URL;
54
+ /** Releases pooled upstream connections. */
55
+ close(): void;
56
+ }
57
+
58
+ const HOP_BY_HOP = new Set([
59
+ "connection",
60
+ "keep-alive",
61
+ "proxy-authenticate",
62
+ "proxy-authorization",
63
+ "proxy-connection",
64
+ "te",
65
+ "trailer",
66
+ "transfer-encoding",
67
+ "upgrade",
68
+ "http2-settings",
69
+ ]);
70
+
71
+ const RETRYABLE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
72
+ const RETRYABLE_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNECT_TIMEOUT"]);
73
+ const TIMEOUT_CODES = new Set(["ETIMEDOUT", "ECONNECT_TIMEOUT"]);
74
+
75
+ class ProxyError extends Error {
76
+ constructor(message: string, readonly code: string) {
77
+ super(message);
78
+ }
79
+ }
80
+
81
+ export function resolveTarget(target: string | undefined): URL {
82
+ if (!target) {
83
+ throw new ConfigError("no destination: pass `target` or set TARGET_API_URL (e.g. http://localhost:4000)");
84
+ }
85
+ let url: URL;
86
+ try {
87
+ url = new URL(target);
88
+ } catch {
89
+ throw new ConfigError(`target "${target}" is not a valid URL`);
90
+ }
91
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
92
+ throw new ConfigError(`target must be http:// or https:// (got ${url.protocol}//)`);
93
+ }
94
+ if (url.username || url.password) {
95
+ throw new ConfigError("target must not contain credentials");
96
+ }
97
+ url.search = "";
98
+ url.hash = "";
99
+ url.pathname = url.pathname.replace(/\/+$/, "");
100
+ return url;
101
+ }
102
+
103
+ /**
104
+ * A reverse proxy for the destination API: clients call it, it forwards to
105
+ * `target`, and every exchange is reported to Midline out of band.
106
+ *
107
+ * The Midline server is never in the request path. If it is down, slow or
108
+ * misconfigured, traffic keeps flowing and only telemetry is buffered.
109
+ */
110
+ export function createMidlineProxy(options: MidlineProxyOptions = {}): MidlineProxy {
111
+ const target = resolveTarget(options.target ?? env("TARGET_API_URL"));
112
+ const extraCa = loadCa(options.targetCa ?? env("TARGET_API_CA"), "targetCa / TARGET_API_CA");
113
+ if (extraCa && target.protocol !== "https:") {
114
+ throw new ConfigError("targetCa / TARGET_API_CA is set but the target is not https://");
115
+ }
116
+
117
+ const timeoutMs = options.timeoutMs ?? envInt("TARGET_API_TIMEOUT_MS") ?? 30_000;
118
+ const connectTimeoutMs = options.connectTimeoutMs ?? 5000;
119
+ const retries = Math.max(0, Math.min(options.retries ?? envInt("TARGET_API_RETRIES") ?? 0, 5));
120
+ const maxRequestBodyBytes = options.maxRequestBodyBytes ?? 10 * 1024 * 1024;
121
+ const captureOverride = options.capture ? resolveCapture(options.capture) : undefined;
122
+ const isHttps = target.protocol === "https:";
123
+ // `pathname` of an http(s) URL is never empty, so a root target reads as "/". Joining
124
+ // that with "/users" would forward "//users".
125
+ const prefix = target.pathname === "/" ? "" : target.pathname;
126
+ const upstreamAgent = isHttps
127
+ ? new https.Agent({ keepAlive: true, ca: trustStore(extraCa) })
128
+ : new http.Agent({ keepAlive: true });
129
+
130
+ let lastNotice = "";
131
+ const warn = (signature: string, message: string) => {
132
+ if (signature === lastNotice) return;
133
+ lastNotice = signature;
134
+ try {
135
+ (options.onError ?? console.warn)(message);
136
+ } catch {
137
+ // ignore
138
+ }
139
+ };
140
+
141
+ const handler = function midlineProxy(req: http.IncomingMessage, res: http.ServerResponse): void {
142
+ const started = process.hrtime.bigint();
143
+ const context = createRequestContext(req.headers);
144
+ const agent = options.agent ?? MidlineAgent.current;
145
+ const capture = captureOverride ?? agent?.capture ?? resolveCapture(undefined);
146
+ const incomingUrl = req.url ?? "/";
147
+
148
+ // Origin-form only ("/path?query"). An absolute-form or authority-form request
149
+ // line would otherwise turn this into an open forward proxy.
150
+ const upstreamUrl = incomingUrl.startsWith("/") ? new URL(`${target.origin}${prefix}${incomingUrl}`) : null;
151
+ if (!upstreamUrl || upstreamUrl.origin !== target.origin) {
152
+ respondError(res, 400, "Bad Request", "Only origin-form request targets are accepted.", context);
153
+ return;
154
+ }
155
+
156
+ const requestTap = capture.requestBody ? new BodyTap(capture.maxBodyBytes) : undefined;
157
+ const responseTap = capture.responseBody ? new BodyTap(capture.maxBodyBytes) : undefined;
158
+ let upstreamResponse: http.IncomingMessage | undefined;
159
+ let failure: { status: number; code: string; message: string } | undefined;
160
+ let recorded = false;
161
+
162
+ const record = () => {
163
+ if (recorded) return;
164
+ recorded = true;
165
+ if (!agent?.active) return;
166
+ const responseEncoding = headerValue(upstreamResponse?.headers["content-encoding"]);
167
+ agent.recordHttp({
168
+ integration: "proxy",
169
+ context,
170
+ method: req.method,
171
+ url: incomingUrl,
172
+ statusCode: failure?.status ?? (res.headersSent ? res.statusCode : 499),
173
+ durationMs: Number(process.hrtime.bigint() - started) / 1e6,
174
+ ip: clientAddress(req, options.trustForwardedHeaders === true),
175
+ userAgent: headerValue(req.headers["user-agent"]),
176
+ aborted: !failure && !res.writableFinished,
177
+ // Origin and path only — a query string may carry credentials.
178
+ destination: { url: `${upstreamUrl.origin}${upstreamUrl.pathname}` },
179
+ errorCode: failure?.code,
180
+ errorMessage: failure?.message,
181
+ capture,
182
+ request: {
183
+ headers: capture.headers ? req.headers : undefined,
184
+ contentType: headerValue(req.headers["content-type"]),
185
+ body: requestTap && requestTap.bytes > 0 && !isCompressed(headerValue(req.headers["content-encoding"])) ? requestTap.body : undefined,
186
+ bodyBytes: requestTap?.bytes,
187
+ truncated: requestTap?.truncated,
188
+ },
189
+ response: {
190
+ headers: capture.headers && upstreamResponse ? upstreamResponse.headers : undefined,
191
+ contentType: headerValue(upstreamResponse?.headers["content-type"]),
192
+ body: responseTap && responseTap.bytes > 0 && !isCompressed(responseEncoding) ? responseTap.body : undefined,
193
+ bodyBytes: responseTap?.bytes,
194
+ truncated: responseTap?.truncated,
195
+ },
196
+ });
197
+ };
198
+ res.once("finish", record);
199
+ res.once("close", record);
200
+
201
+ const declaredLength = Number(req.headers["content-length"]);
202
+ if (Number.isFinite(declaredLength) && declaredLength > maxRequestBodyBytes) {
203
+ failure = { status: 413, code: "EBODY_TOO_LARGE", message: `request body of ${declaredLength} bytes exceeds ${maxRequestBodyBytes}` };
204
+ res.setHeader("connection", "close");
205
+ respondError(res, 413, "Payload Too Large", "Request body is too large.", context);
206
+ req.resume();
207
+ return;
208
+ }
209
+
210
+ const hasBody = (Number.isFinite(declaredLength) && declaredLength > 0) || req.headers["transfer-encoding"] !== undefined;
211
+ const canRetry = retries > 0 && !hasBody && RETRYABLE_METHODS.has((req.method ?? "GET").toUpperCase());
212
+ const headers = forwardHeaders(req, target, context, options);
213
+
214
+ let bodyBytes = 0;
215
+ let bodyTooLarge = false;
216
+
217
+ const attempt = (attemptNumber: number) => {
218
+ let connectTimer: NodeJS.Timeout | undefined;
219
+ const upstream = (isHttps ? https : http).request(upstreamUrl, {
220
+ method: req.method,
221
+ headers,
222
+ agent: upstreamAgent,
223
+ });
224
+
225
+ const responseTimer = setTimeout(() => {
226
+ upstream.destroy(new ProxyError(`destination did not respond within ${timeoutMs}ms`, "ETIMEDOUT"));
227
+ }, timeoutMs);
228
+
229
+ upstream.on("socket", (socket) => {
230
+ if ((socket as any).connecting) {
231
+ connectTimer = setTimeout(() => {
232
+ upstream.destroy(new ProxyError(`could not connect to the destination within ${connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
233
+ }, connectTimeoutMs);
234
+ socket.once(isHttps ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
235
+ }
236
+ });
237
+
238
+ upstream.on("response", (response) => {
239
+ clearTimeout(responseTimer);
240
+ if (connectTimer) clearTimeout(connectTimer);
241
+ upstreamResponse = response;
242
+ lastNotice = "";
243
+
244
+ res.writeHead(response.statusCode ?? 502, response.statusMessage, stripHopByHop(response.headers));
245
+ response.on("data", (chunk) => responseTap?.push(chunk));
246
+ response.on("error", () => res.destroy());
247
+ response.on("aborted", () => res.destroy());
248
+ response.pipe(res);
249
+ });
250
+
251
+ upstream.on("error", (err: any) => {
252
+ clearTimeout(responseTimer);
253
+ if (connectTimer) clearTimeout(connectTimer);
254
+ const code = String(err?.code ?? "EUPSTREAM");
255
+
256
+ // A destroyed request can report more than one error; answer the first.
257
+ if (failure) return;
258
+ if (upstreamResponse) {
259
+ // Failed mid-body: the status line is already out, so all that's left is to cut the connection.
260
+ res.destroy();
261
+ return;
262
+ }
263
+ if (bodyTooLarge) {
264
+ failure = { status: 413, code: "EBODY_TOO_LARGE", message: `request body exceeded ${maxRequestBodyBytes} bytes` };
265
+ res.setHeader("connection", "close");
266
+ respondError(res, 413, "Payload Too Large", "Request body is too large.", context);
267
+ return;
268
+ }
269
+ if (res.destroyed || res.writableEnded) {
270
+ // The client is gone; the close handler records it as aborted.
271
+ return;
272
+ }
273
+ if (canRetry && attemptNumber < retries && RETRYABLE_CODES.has(code)) {
274
+ setTimeout(() => attempt(attemptNumber + 1), Math.min(100 * 2 ** attemptNumber, 2000));
275
+ return;
276
+ }
277
+
278
+ const timedOut = TIMEOUT_CODES.has(code);
279
+ const status = timedOut ? 504 : 502;
280
+ failure = { status, code, message: describeUpstreamError(code, err, target) };
281
+ warn(code, `midline proxy: ${failure.message}`);
282
+ respondError(res, status, timedOut ? "Gateway Timeout" : "Bad Gateway", "The destination API is unavailable.", context);
283
+ });
284
+
285
+ // The client went away: stop the upstream work too.
286
+ res.once("close", () => {
287
+ if (!res.writableFinished) upstream.destroy();
288
+ });
289
+
290
+ if (!hasBody) {
291
+ upstream.end();
292
+ return;
293
+ }
294
+
295
+ req.on("data", (chunk: Buffer) => {
296
+ bodyBytes += chunk.length;
297
+ if (bodyBytes > maxRequestBodyBytes && !bodyTooLarge) {
298
+ bodyTooLarge = true;
299
+ req.unpipe(upstream);
300
+ upstream.destroy(new ProxyError("request body too large", "EBODY_TOO_LARGE"));
301
+ req.resume();
302
+ return;
303
+ }
304
+ requestTap?.push(chunk);
305
+ });
306
+ req.pipe(upstream);
307
+ };
308
+
309
+ attempt(0);
310
+ } as MidlineProxy;
311
+
312
+ Object.defineProperty(handler, "target", { value: target, enumerable: true });
313
+ (handler as { close: () => void }).close = () => upstreamAgent.destroy();
314
+ return handler;
315
+ }
316
+
317
+ export interface StartProxyOptions extends MidlineProxyOptions {
318
+ /** Default 8080. Env: `MIDLINE_PROXY_PORT`. */
319
+ port?: number;
320
+ /** Default 127.0.0.1 — set 0.0.0.0 explicitly to accept traffic from other machines. Env: `MIDLINE_PROXY_HOST`. */
321
+ host?: string;
322
+ /** Used to create an agent when `agent` isn't passed and none is initialised. */
323
+ midline?: MidlineConfig;
324
+ }
325
+
326
+ export async function startMidlineProxy(options: StartProxyOptions = {}): Promise<http.Server & { proxy: MidlineProxy }> {
327
+ const agent = options.agent ?? MidlineAgent.current ?? MidlineAgent.init(options.midline ?? {});
328
+ const proxy = createMidlineProxy({ ...options, agent });
329
+ const server = http.createServer(proxy) as http.Server & { proxy: MidlineProxy };
330
+ server.proxy = proxy;
331
+ server.on("close", () => proxy.close());
332
+
333
+ const port = options.port ?? envInt("MIDLINE_PROXY_PORT") ?? 8080;
334
+ const host = options.host ?? env("MIDLINE_PROXY_HOST") ?? "127.0.0.1";
335
+ await new Promise<void>((resolve, reject) => {
336
+ server.once("error", reject);
337
+ server.listen(port, host, () => {
338
+ server.off("error", reject);
339
+ resolve();
340
+ });
341
+ });
342
+ return server;
343
+ }
344
+
345
+ export function proxyAddress(server: http.Server): string {
346
+ const address = server.address() as AddressInfo;
347
+ const host = address.family === "IPv6" ? `[${address.address}]` : address.address;
348
+ return `http://${host}:${address.port}`;
349
+ }
350
+
351
+ function forwardHeaders(
352
+ req: http.IncomingMessage,
353
+ target: URL,
354
+ context: RequestContext,
355
+ options: MidlineProxyOptions,
356
+ ): http.OutgoingHttpHeaders {
357
+ const out = stripHopByHop(req.headers);
358
+ const trustForwarded = options.trustForwardedHeaders === true;
359
+ const clientIp = req.socket.remoteAddress ?? "";
360
+ const incomingFor = headerValue(req.headers["x-forwarded-for"]);
361
+
362
+ if (!options.preserveHost) {
363
+ out.host = target.host;
364
+ }
365
+ // Already answered by this server; forwarding it would make the destination wait for a body it already has.
366
+ delete out.expect;
367
+
368
+ const proto = (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http";
369
+ out["x-forwarded-for"] = trustForwarded && incomingFor ? `${incomingFor}, ${clientIp}` : clientIp;
370
+ out["x-forwarded-proto"] = (trustForwarded && headerValue(req.headers["x-forwarded-proto"])) || proto;
371
+ out["x-forwarded-host"] = (trustForwarded && headerValue(req.headers["x-forwarded-host"])) || headerValue(req.headers.host) || "";
372
+ out["x-request-id"] = context.requestId;
373
+ out["x-correlation-id"] = context.correlationId;
374
+ return out;
375
+ }
376
+
377
+ function stripHopByHop(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
378
+ const named = new Set(
379
+ String(headers.connection ?? "")
380
+ .split(",")
381
+ .map((token) => token.trim().toLowerCase())
382
+ .filter(Boolean),
383
+ );
384
+ const out: http.OutgoingHttpHeaders = {};
385
+ for (const [name, value] of Object.entries(headers)) {
386
+ if (value === undefined || HOP_BY_HOP.has(name) || named.has(name)) continue;
387
+ out[name] = value;
388
+ }
389
+ return out;
390
+ }
391
+
392
+ function clientAddress(req: http.IncomingMessage, trustForwarded: boolean): string | undefined {
393
+ if (trustForwarded) {
394
+ const forwarded = headerValue(req.headers["x-forwarded-for"])?.split(",")[0]?.trim();
395
+ if (forwarded) return forwarded;
396
+ }
397
+ return req.socket.remoteAddress;
398
+ }
399
+
400
+ function respondError(res: http.ServerResponse, status: number, error: string, message: string, context: RequestContext): void {
401
+ if (res.headersSent) {
402
+ res.destroy();
403
+ return;
404
+ }
405
+ const body = JSON.stringify({ error, message, requestId: context.requestId });
406
+ res.writeHead(status, {
407
+ "content-type": "application/json; charset=utf-8",
408
+ "content-length": Buffer.byteLength(body),
409
+ "x-request-id": context.requestId,
410
+ });
411
+ res.end(body);
412
+ }
413
+
414
+ function describeUpstreamError(code: string, err: any, target: URL): string {
415
+ const where = target.origin;
416
+ switch (code) {
417
+ case "ECONNREFUSED":
418
+ return `${where} refused the connection (is the destination API running?)`;
419
+ case "ENOTFOUND":
420
+ case "EAI_AGAIN":
421
+ return `cannot resolve ${target.hostname} (${code}) — check TARGET_API_URL`;
422
+ case "ETIMEDOUT":
423
+ case "ECONNECT_TIMEOUT":
424
+ return `${where}: ${err?.message ?? code}`;
425
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
426
+ case "SELF_SIGNED_CERT_IN_CHAIN":
427
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
428
+ case "UNABLE_TO_GET_ISSUER_CERT_LOCALLY":
429
+ case "CERT_HAS_EXPIRED":
430
+ case "ERR_TLS_CERT_ALTNAME_INVALID":
431
+ return `${where} presented a certificate that could not be verified (${code}); verification stays on — if the destination uses a private CA, set targetCa / TARGET_API_CA`;
432
+ default:
433
+ return `${where}: ${err?.message ?? code} (${code})`;
434
+ }
435
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Redaction happens in the host process, before an event is queued. Whatever is
3
+ * removed here never reaches a socket, a log line or the Midline server.
4
+ *
5
+ * Matching is on a normalised key — lower-cased with punctuation stripped — so
6
+ * `X-API-Key`, `api_key` and `apiKey` are all the same key.
7
+ */
8
+
9
+ export const REDACTED = "[REDACTED]";
10
+
11
+ /** Substrings: any key containing one of these is sensitive. */
12
+ const SENSITIVE_KEY_PARTS = [
13
+ "password",
14
+ "passwd",
15
+ "passphrase",
16
+ "secret",
17
+ "token",
18
+ "apikey",
19
+ "accesskey",
20
+ "privatekey",
21
+ "authorization",
22
+ "cookie",
23
+ "session",
24
+ "credential",
25
+ "csrf",
26
+ "xsrf",
27
+ "signature",
28
+ "creditcard",
29
+ "cardnumber",
30
+ "cvv",
31
+ "cvc",
32
+ "ssn",
33
+ "socialsecurity",
34
+ ];
35
+
36
+ /** Whole keys only — as substrings these would hit words like "author" or "spinner". */
37
+ const SENSITIVE_KEYS_EXACT = new Set(["auth", "pwd", "pin", "otp", "sid", "jwt", "bearer"]);
38
+
39
+ /** Always redacted by name, even if a user-supplied list somehow unmatched them. */
40
+ export const DEFAULT_SENSITIVE_HEADERS = [
41
+ "authorization",
42
+ "proxy-authorization",
43
+ "cookie",
44
+ "set-cookie",
45
+ "x-api-key",
46
+ "api-key",
47
+ "x-auth-token",
48
+ "x-access-token",
49
+ "x-refresh-token",
50
+ "x-csrf-token",
51
+ "x-xsrf-token",
52
+ "x-amz-security-token",
53
+ ];
54
+
55
+ const VALUE_PATTERNS: Array<[RegExp, string]> = [
56
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, REDACTED],
57
+ [/\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, `$1 ${REDACTED}`],
58
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, REDACTED],
59
+ [/\bak_[A-Fa-f0-9]{16,}\b/g, REDACTED],
60
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, REDACTED],
61
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
62
+ [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s\/@:]+:[^\s\/@]+@/gi, `$1${REDACTED}@`],
63
+ ];
64
+
65
+ /** `key=value` / `key: value` in query strings, log lines and error messages. */
66
+ const KEY_VALUE_PAIR = /(^|[?&;,\s(\[{])([A-Za-z0-9_.%-]{1,64})(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&#,;)\]}]+)/g;
67
+ /** `"key": value` in JSON text, including JSON that was cut off mid-value. */
68
+ const JSON_PAIR = /"([^"\\]{1,100})"\s*:\s*("(?:[^"\\]|\\.)*"?|-?\d+(?:\.\d+)?|true|false|null)/g;
69
+
70
+ export function normalizeKey(key: string): string {
71
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
72
+ }
73
+
74
+ export class Redactor {
75
+ private readonly extraKeys: string[];
76
+ private readonly headerNames: Set<string>;
77
+
78
+ constructor(extraFields: string[] = [], extraHeaders: string[] = []) {
79
+ this.extraKeys = extraFields.map(normalizeKey).filter(Boolean);
80
+ this.headerNames = new Set(
81
+ [...DEFAULT_SENSITIVE_HEADERS, ...extraHeaders].map((name) => name.toLowerCase()),
82
+ );
83
+ }
84
+
85
+ isSensitiveKey(key: string): boolean {
86
+ const normalized = normalizeKey(key);
87
+ if (!normalized) return false;
88
+ if (SENSITIVE_KEYS_EXACT.has(normalized)) return true;
89
+ if (SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part))) return true;
90
+ return this.extraKeys.some((part) => normalized.includes(part));
91
+ }
92
+
93
+ /** Masks credentials embedded in free text: bearer tokens, JWTs, key formats, URL userinfo and query params. */
94
+ string(value: string, maxLength = 2048): string {
95
+ let out = value.length > maxLength * 4 ? value.slice(0, maxLength * 4) : value;
96
+ for (const [pattern, replacement] of VALUE_PATTERNS) {
97
+ out = out.replace(pattern, replacement);
98
+ }
99
+ if (out.includes("=") || out.includes(":")) {
100
+ out = out.replace(KEY_VALUE_PAIR, (match, sep: string, key: string, delimiter: string) => {
101
+ let decoded = key;
102
+ try {
103
+ decoded = decodeURIComponent(key);
104
+ } catch {
105
+ // keep the raw key
106
+ }
107
+ return this.isSensitiveKey(decoded) ? `${sep}${key}${delimiter}${REDACTED}` : match;
108
+ });
109
+ }
110
+ if (out.includes('"')) {
111
+ out = out.replace(JSON_PAIR, (match, key: string) =>
112
+ this.isSensitiveKey(key) ? `"${key}":"${REDACTED}"` : match,
113
+ );
114
+ }
115
+ // The ellipsis counts toward the limit: servers validate these lengths exactly.
116
+ return out.length > maxLength ? `${out.slice(0, Math.max(0, maxLength - 1))}…` : out;
117
+ }
118
+
119
+ /** Deep copy with sensitive keys and values masked. Bounded in depth, breadth and string length. */
120
+ value(input: unknown, depth = 0, seen: WeakSet<object> = new WeakSet()): unknown {
121
+ if (input === null || input === undefined) return input;
122
+ if (typeof input === "string") return this.string(input);
123
+ if (typeof input === "number" || typeof input === "boolean") return input;
124
+ if (typeof input === "bigint") return input.toString();
125
+ if (typeof input === "function" || typeof input === "symbol") return undefined;
126
+ if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.toISOString();
127
+ if (Buffer.isBuffer(input) || ArrayBuffer.isView(input)) {
128
+ return `[Binary ${(input as ArrayBufferView).byteLength} bytes]`;
129
+ }
130
+ if (typeof input !== "object") return undefined;
131
+
132
+ if (seen.has(input)) return "[Circular]";
133
+ if (depth >= 8) return "[Truncated]";
134
+ seen.add(input);
135
+
136
+ if (Array.isArray(input)) {
137
+ const items = input.slice(0, 100).map((item) => this.value(item, depth + 1, seen));
138
+ if (input.length > 100) items.push(`[${input.length - 100} more]`);
139
+ return items;
140
+ }
141
+
142
+ const out: Record<string, unknown> = {};
143
+ let count = 0;
144
+ for (const [key, nested] of Object.entries(input as Record<string, unknown>)) {
145
+ if (count++ >= 200) {
146
+ out["[truncated]"] = "too many keys";
147
+ break;
148
+ }
149
+ out[key] = this.isSensitiveKey(key) ? REDACTED : this.value(nested, depth + 1, seen);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ headers(headers: Record<string, unknown> | undefined): Record<string, string> | undefined {
155
+ if (!headers) return undefined;
156
+ const out: Record<string, string> = {};
157
+ for (const [rawName, rawValue] of Object.entries(headers)) {
158
+ if (rawValue === undefined) continue;
159
+ const name = rawName.toLowerCase();
160
+ if (this.headerNames.has(name) || this.isSensitiveKey(name)) {
161
+ out[name] = REDACTED;
162
+ continue;
163
+ }
164
+ const joined = Array.isArray(rawValue) ? rawValue.join(", ") : String(rawValue);
165
+ out[name] = this.string(joined, 1024);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ query(search: string): Record<string, string | string[]> | undefined {
171
+ if (!search) return undefined;
172
+ const out: Record<string, string | string[]> = {};
173
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
174
+ for (const key of new Set(params.keys())) {
175
+ const values = params.getAll(key).map((value) =>
176
+ this.isSensitiveKey(key) ? REDACTED : this.string(value, 512),
177
+ );
178
+ out[key] = values.length === 1 ? values[0] : values;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Redacts a captured body. Structured content is parsed and redacted by key;
185
+ * text that cannot be parsed (usually because it was truncated) gets key/value
186
+ * pattern masking instead, so a cut-off JSON body still loses its passwords.
187
+ */
188
+ body(raw: unknown, contentType: string | undefined, maxBytes: number): { body?: unknown; truncated?: boolean; omitted?: string } {
189
+ if (raw === undefined || raw === null || maxBytes <= 0) return {};
190
+
191
+ const type = (contentType || "").toLowerCase();
192
+
193
+ if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
194
+ return this.fit(this.value(raw), maxBytes);
195
+ }
196
+
197
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
198
+
199
+ if (type.includes("json")) {
200
+ try {
201
+ return this.fit(this.value(JSON.parse(text)), maxBytes);
202
+ } catch {
203
+ return this.cut(this.string(text, text.length), maxBytes);
204
+ }
205
+ }
206
+ if (type.includes("application/x-www-form-urlencoded")) {
207
+ return this.fit(this.query(text) ?? {}, maxBytes);
208
+ }
209
+ if (!type || type.startsWith("text/") || type.includes("xml") || type.includes("graphql")) {
210
+ return this.cut(this.string(text, maxBytes * 2), maxBytes);
211
+ }
212
+ return { omitted: `content-type ${type.split(";")[0]}` };
213
+ }
214
+
215
+ private fit(value: unknown, maxBytes: number): { body?: unknown; truncated?: boolean } {
216
+ const serialized = JSON.stringify(value) ?? "";
217
+ if (Buffer.byteLength(serialized) <= maxBytes) {
218
+ return { body: value };
219
+ }
220
+ return this.cut(serialized, maxBytes);
221
+ }
222
+
223
+ private cut(text: string, maxBytes: number): { body?: unknown; truncated?: boolean } {
224
+ const bytes = Buffer.from(text);
225
+ if (bytes.length <= maxBytes) {
226
+ return { body: text };
227
+ }
228
+ // Slicing bytes can split a multi-byte character; the replacement char is harmless here.
229
+ return { body: bytes.subarray(0, maxBytes).toString("utf8"), truncated: true };
230
+ }
231
+ }