midline-agent 0.1.9 → 0.3.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.
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const express = require("express");
6
+ const { MidlineAgent, midlineMiddleware, midlineErrorHandler, getRequestContext } = require("../dist");
7
+ const { REDACTED } = require("../dist/redact");
8
+ const { startCollector, listen, request, closedPort, waitFor } = require("./helpers");
9
+
10
+ test("express: captured headers, query and bodies are redacted before they leave the process", async () => {
11
+ const collector = await startCollector();
12
+ const agent = new MidlineAgent({
13
+ apiKey: "ak_mw",
14
+ endpoint: collector.url,
15
+ flushIntervalMs: 60_000,
16
+ capture: { headers: true, query: true, requestBody: true, responseBody: true },
17
+ });
18
+
19
+ const app = express();
20
+ app.use(midlineMiddleware({ agent }));
21
+ app.use(express.json());
22
+ app.post("/login", (req, res) => {
23
+ res.set("set-cookie", "session=abc");
24
+ res.json({ access_token: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl", user: req.body.email, requestId: getRequestContext(req).requestId });
25
+ });
26
+ const server = await listen(app);
27
+
28
+ try {
29
+ const response = await request(`http://127.0.0.1:${server.port}/login?token=abc&page=2`, {
30
+ method: "POST",
31
+ headers: {
32
+ "content-type": "application/json",
33
+ authorization: "Bearer supersecretvalue123",
34
+ cookie: "sid=1",
35
+ "x-request-id": "incoming-req-1",
36
+ "x-correlation-id": "flow-9",
37
+ },
38
+ body: JSON.stringify({ email: "ada@example.com", password: "hunter2" }),
39
+ });
40
+ assert.equal(response.status, 200);
41
+ assert.equal(response.json.requestId, "incoming-req-1");
42
+
43
+ await waitFor(() => agent.queued === 1);
44
+ await agent.flush();
45
+ const [event] = collector.events();
46
+
47
+ assert.equal(event.route, "/login");
48
+ assert.equal(event.method, "POST");
49
+ assert.equal(event.statusCode, 200);
50
+ assert.equal(event.metadata.integrationType, "express");
51
+ assert.equal(event.metadata.requestId, "incoming-req-1");
52
+ assert.equal(event.metadata.correlationId, "flow-9");
53
+ assert.equal(event.metadata.routeTemplate, "/login");
54
+
55
+ const { request: req, response: res } = event.payload;
56
+ assert.equal(req.headers.authorization, REDACTED);
57
+ assert.equal(req.headers.cookie, REDACTED);
58
+ assert.equal(req.headers["content-type"], "application/json");
59
+ assert.deepEqual(req.query, { token: REDACTED, page: "2" });
60
+ assert.deepEqual(req.body, { email: "ada@example.com", password: REDACTED });
61
+ assert.equal(res.headers["set-cookie"], REDACTED);
62
+ assert.equal(res.body.access_token, REDACTED);
63
+ assert.equal(res.body.user, "ada@example.com");
64
+
65
+ const serialized = JSON.stringify(collector.requests);
66
+ for (const secret of ["supersecretvalue123", "hunter2", "sid=1", "session=abc", "eyJhbGciOiJIUzI1NiJ9"]) {
67
+ assert.ok(!serialized.includes(secret), `${secret} reached the wire`);
68
+ }
69
+ } finally {
70
+ agent.close();
71
+ await server.close();
72
+ await collector.close();
73
+ }
74
+ });
75
+
76
+ test("express: nothing is captured beyond method/path/status unless asked", async () => {
77
+ const collector = await startCollector();
78
+ const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
79
+ const app = express();
80
+ app.use(midlineMiddleware({ agent }));
81
+ app.use(express.json());
82
+ app.post("/items", (req, res) => res.status(201).json({ ok: true }));
83
+ const server = await listen(app);
84
+ try {
85
+ await request(`http://127.0.0.1:${server.port}/items?secret=1`, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", authorization: "Bearer abcdefghijkl" },
88
+ body: JSON.stringify({ name: "x" }),
89
+ });
90
+ await waitFor(() => agent.queued === 1);
91
+ await agent.flush();
92
+ const [event] = collector.events();
93
+ assert.equal(event.statusCode, 201);
94
+ assert.equal(event.payload, undefined);
95
+ } finally {
96
+ agent.close();
97
+ await server.close();
98
+ await collector.close();
99
+ }
100
+ });
101
+
102
+ test("express: errors are recorded and still reach the app's own handler", async () => {
103
+ const collector = await startCollector();
104
+ const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
105
+ const app = express();
106
+ app.use(midlineMiddleware({ agent }));
107
+ app.get("/boom", () => {
108
+ throw Object.assign(new Error("db password=hunter2 failed"), { status: 503 });
109
+ });
110
+ app.use(midlineErrorHandler({ agent }));
111
+ app.use((err, req, res, next) => res.status(err.status).json({ handledBy: "app" }));
112
+ const server = await listen(app);
113
+ try {
114
+ const response = await request(`http://127.0.0.1:${server.port}/boom`);
115
+ assert.equal(response.status, 503);
116
+ assert.equal(response.json.handledBy, "app");
117
+
118
+ await waitFor(() => agent.queued === 2);
119
+ await agent.flush();
120
+ const events = collector.events();
121
+ const error = events.find((event) => event.eventType === "error");
122
+ assert.equal(error.statusCode, 503);
123
+ assert.equal(error.payload.error, `db password=${REDACTED} failed`);
124
+ assert.ok(error.metadata.requestId, "error is tied to the request id");
125
+ assert.equal(events.find((event) => event.eventType === "request").metadata.requestId, error.metadata.requestId);
126
+ } finally {
127
+ agent.close();
128
+ await server.close();
129
+ await collector.close();
130
+ }
131
+ });
132
+
133
+ test("the host app is unaffected when the Midline server is unreachable or the agent is off", async () => {
134
+ const port = await closedPort();
135
+ const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: `http://127.0.0.1:${port}`, flushIntervalMs: 20, onError: () => {} });
136
+ const off = new MidlineAgent({ enabled: false });
137
+
138
+ const app = express();
139
+ app.use(midlineMiddleware({ agent }));
140
+ app.use(midlineMiddleware({ agent: off }));
141
+ app.get("/health", (req, res) => res.json({ ok: true }));
142
+ const server = await listen(app);
143
+ try {
144
+ for (let i = 0; i < 5; i++) {
145
+ const response = await request(`http://127.0.0.1:${server.port}/health`);
146
+ assert.equal(response.status, 200);
147
+ }
148
+ await new Promise((resolve) => setTimeout(resolve, 100));
149
+ assert.ok(agent.active);
150
+ } finally {
151
+ agent.close();
152
+ await server.close();
153
+ }
154
+ });
155
+
156
+ test("plain node http servers work with the same middleware", async () => {
157
+ const collector = await startCollector();
158
+ const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
159
+ const middleware = midlineMiddleware({ agent });
160
+ const server = await listen((req, res) => {
161
+ middleware(req, res, () => {
162
+ res.writeHead(404, { "content-type": "text/plain" });
163
+ res.end("nope");
164
+ });
165
+ });
166
+ try {
167
+ await request(`http://127.0.0.1:${server.port}/missing`);
168
+ await waitFor(() => agent.queued === 1);
169
+ await agent.flush();
170
+ const [event] = collector.events();
171
+ assert.equal(event.statusCode, 404);
172
+ assert.equal(event.metadata.integrationType, "node-http");
173
+ } finally {
174
+ agent.close();
175
+ await server.close();
176
+ await collector.close();
177
+ }
178
+ });
@@ -0,0 +1,274 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const net = require("net");
6
+ const { MidlineAgent, createMidlineProxy, startMidlineProxy, ConfigError } = require("../dist");
7
+ const { REDACTED } = require("../dist/redact");
8
+ const { makeCerts, startCollector, listen, request, closedPort, waitFor } = require("./helpers");
9
+
10
+ const certs = makeCerts();
11
+ const needsOpenssl = certs ? {} : { skip: "openssl not available" };
12
+
13
+ /** A destination API that echoes what it received. */
14
+ function echo(req, res) {
15
+ const chunks = [];
16
+ req.on("data", (chunk) => chunks.push(chunk));
17
+ req.on("end", () => {
18
+ const body = JSON.stringify({
19
+ method: req.method,
20
+ url: req.url,
21
+ headers: req.headers,
22
+ body: Buffer.concat(chunks).toString("utf8"),
23
+ });
24
+ res.writeHead(200, { "content-type": "application/json", connection: "x-hop", "x-hop": "should-not-pass" });
25
+ res.end(body);
26
+ });
27
+ }
28
+
29
+ async function setup({ target, collectorOptions, agentOptions, proxyOptions } = {}) {
30
+ const collector = await startCollector(collectorOptions);
31
+ const agent = new MidlineAgent({ apiKey: "ak_proxy", endpoint: collector.url, flushIntervalMs: 60_000, ...agentOptions });
32
+ const server = await startMidlineProxy({ target, port: 0, agent, onError: () => {}, ...proxyOptions });
33
+ const base = `http://127.0.0.1:${server.address().port}`;
34
+ return {
35
+ collector,
36
+ agent,
37
+ base,
38
+ close: async () => {
39
+ agent.close();
40
+ server.closeAllConnections?.();
41
+ await new Promise((resolve) => server.close(resolve));
42
+ await collector.close();
43
+ },
44
+ };
45
+ }
46
+
47
+ test("config: the destination is required, validated and never hardcoded", () => {
48
+ const previous = process.env.TARGET_API_URL;
49
+ delete process.env.TARGET_API_URL;
50
+ assert.throws(() => createMidlineProxy({}), /TARGET_API_URL/);
51
+ assert.throws(() => createMidlineProxy({ target: "ftp://x" }), ConfigError);
52
+ assert.throws(() => createMidlineProxy({ target: "http://user:pw@x" }), /credentials/);
53
+ assert.throws(() => createMidlineProxy({ target: "http://localhost:4000", targetCa: "/nope.pem" }), /cannot read CA file/);
54
+
55
+ process.env.TARGET_API_URL = "https://staging.example.com/v2/";
56
+ const proxy = createMidlineProxy({});
57
+ assert.equal(proxy.target.href, "https://staging.example.com/v2");
58
+ proxy.close();
59
+ if (previous === undefined) delete process.env.TARGET_API_URL;
60
+ else process.env.TARGET_API_URL = previous;
61
+ });
62
+
63
+ test("forwards to the destination and reports the exchange to Midline separately", async () => {
64
+ const destination = await listen(echo);
65
+ const ctx = await setup({
66
+ target: `http://127.0.0.1:${destination.port}/base`,
67
+ agentOptions: { capture: { headers: true, query: true, requestBody: true, responseBody: true } },
68
+ });
69
+ try {
70
+ const response = await request(`${ctx.base}/v1/items?page=2&api_key=abc`, {
71
+ method: "POST",
72
+ headers: {
73
+ "content-type": "application/json",
74
+ authorization: "Bearer clientsecretvalue",
75
+ "proxy-authorization": "Basic Zm9vOmJhcg==",
76
+ "x-request-id": "rid-7",
77
+ },
78
+ body: JSON.stringify({ name: "widget", password: "p4ss" }),
79
+ });
80
+
81
+ assert.equal(response.status, 200);
82
+ const seen = response.json;
83
+ assert.equal(seen.method, "POST");
84
+ assert.equal(seen.url, "/base/v1/items?page=2&api_key=abc", "path and query forwarded unchanged under the target prefix");
85
+ assert.equal(seen.body, JSON.stringify({ name: "widget", password: "p4ss" }), "the destination gets the real body");
86
+ assert.equal(seen.headers.authorization, "Bearer clientsecretvalue", "the destination gets the real credentials");
87
+ assert.equal(seen.headers.host, `127.0.0.1:${destination.port}`);
88
+ assert.equal(seen.headers["proxy-authorization"], undefined, "hop-by-hop headers are not forwarded");
89
+ assert.equal(seen.headers["x-request-id"], "rid-7");
90
+ assert.ok(seen.headers["x-forwarded-for"]);
91
+ assert.equal(response.headers["x-hop"], undefined, "hop-by-hop response headers are stripped");
92
+
93
+ await waitFor(() => ctx.agent.queued === 1);
94
+ await ctx.agent.flush();
95
+ const [event] = ctx.collector.events();
96
+ assert.equal(ctx.collector.requests[0].url, "/api/api-monitor/events/batch", "events go to Midline, not the destination");
97
+ assert.equal(event.route, "/v1/items");
98
+ assert.equal(event.metadata.integrationType, "proxy");
99
+ assert.equal(event.metadata.requestId, "rid-7");
100
+ assert.equal(event.payload.destination.url, `http://127.0.0.1:${destination.port}/base/v1/items`);
101
+ assert.equal(event.payload.request.headers.authorization, REDACTED);
102
+ assert.deepEqual(event.payload.request.query, { page: "2", api_key: REDACTED });
103
+ assert.deepEqual(event.payload.request.body, { name: "widget", password: REDACTED });
104
+ assert.equal(event.payload.response.body.body.includes("p4ss"), false, "echoed secrets are masked in the captured response too");
105
+ } finally {
106
+ await ctx.close();
107
+ await destination.close();
108
+ }
109
+ });
110
+
111
+ test("destination down: 502 to the client, an infrastructure error event to Midline", async () => {
112
+ const port = await closedPort();
113
+ const ctx = await setup({ target: `http://127.0.0.1:${port}` });
114
+ try {
115
+ const response = await request(`${ctx.base}/orders`);
116
+ assert.equal(response.status, 502);
117
+ assert.equal(response.json.error, "Bad Gateway");
118
+ assert.ok(response.json.requestId);
119
+ assert.doesNotMatch(response.text, /127\.0\.0\.1|ECONNREFUSED/, "internal details are not leaked to clients");
120
+
121
+ await waitFor(() => ctx.agent.queued === 1);
122
+ await ctx.agent.flush();
123
+ const [event] = ctx.collector.events();
124
+ assert.equal(event.eventType, "error");
125
+ assert.equal(event.statusCode, 502);
126
+ assert.equal(event.category, "infrastructure");
127
+ assert.equal(event.payload.code, "ECONNREFUSED");
128
+ } finally {
129
+ await ctx.close();
130
+ }
131
+ });
132
+
133
+ test("slow destination: 504 after timeoutMs", async () => {
134
+ const sockets = new Set();
135
+ const destination = await listen(() => {});
136
+ destination.server.on("connection", (socket) => sockets.add(socket));
137
+ const ctx = await setup({ target: `http://127.0.0.1:${destination.port}`, proxyOptions: { timeoutMs: 150 } });
138
+ try {
139
+ const response = await request(`${ctx.base}/slow`);
140
+ assert.equal(response.status, 504);
141
+ } finally {
142
+ for (const socket of sockets) socket.destroy();
143
+ await ctx.close();
144
+ await destination.close();
145
+ }
146
+ });
147
+
148
+ test("retries: idempotent requests that never reached the destination are retried, writes are not", async () => {
149
+ let attempts = 0;
150
+ const destination = await listen((req, res) => {
151
+ attempts += 1;
152
+ if (attempts === 1) {
153
+ req.socket.destroy();
154
+ return;
155
+ }
156
+ echo(req, res);
157
+ });
158
+ const ctx = await setup({ target: `http://127.0.0.1:${destination.port}`, proxyOptions: { retries: 2 } });
159
+ try {
160
+ const ok = await request(`${ctx.base}/flaky`);
161
+ assert.equal(ok.status, 200);
162
+ assert.equal(attempts, 2);
163
+
164
+ attempts = 0;
165
+ const write = await request(`${ctx.base}/flaky`, { method: "POST", body: "x", headers: { "content-length": "1" } });
166
+ assert.equal(write.status, 502);
167
+ assert.equal(attempts, 1, "a POST that may have been applied is never replayed");
168
+ } finally {
169
+ await ctx.close();
170
+ await destination.close();
171
+ }
172
+ });
173
+
174
+ test("request bodies over the limit get 413", async () => {
175
+ const destination = await listen(echo);
176
+ const ctx = await setup({ target: `http://127.0.0.1:${destination.port}`, proxyOptions: { maxRequestBodyBytes: 10 } });
177
+ try {
178
+ const response = await request(`${ctx.base}/upload`, { method: "POST", body: "x".repeat(100) });
179
+ assert.equal(response.status, 413);
180
+ } finally {
181
+ await ctx.close();
182
+ await destination.close();
183
+ }
184
+ });
185
+
186
+ test("not an open proxy: absolute-form request lines are refused, // paths stay on the target", async () => {
187
+ const destination = await listen(echo);
188
+ const ctx = await setup({ target: `http://127.0.0.1:${destination.port}` });
189
+ try {
190
+ const raw = await new Promise((resolve, reject) => {
191
+ const socket = net.connect(Number(new URL(ctx.base).port), "127.0.0.1", () => {
192
+ socket.write("GET http://169.254.169.254/latest/meta-data HTTP/1.1\r\nHost: 169.254.169.254\r\nConnection: close\r\n\r\n");
193
+ });
194
+ let data = "";
195
+ socket.on("data", (chunk) => (data += chunk));
196
+ socket.on("end", () => resolve(data));
197
+ socket.on("error", reject);
198
+ });
199
+ assert.match(raw, /^HTTP\/1\.1 400/);
200
+
201
+ const sneaky = await request(`${ctx.base}//evil.example/x`);
202
+ assert.equal(sneaky.status, 200);
203
+ assert.equal(sneaky.json.url, "//evil.example/x");
204
+ assert.equal(sneaky.json.headers.host, `127.0.0.1:${destination.port}`);
205
+ } finally {
206
+ await ctx.close();
207
+ await destination.close();
208
+ }
209
+ });
210
+
211
+ test("Midline unreachable: traffic still flows through the proxy", async () => {
212
+ const destination = await listen(echo);
213
+ const port = await closedPort();
214
+ const agent = new MidlineAgent({ apiKey: "ak_proxy", endpoint: `http://127.0.0.1:${port}`, flushIntervalMs: 20, onError: () => {} });
215
+ const server = await startMidlineProxy({ target: `http://127.0.0.1:${destination.port}`, port: 0, agent });
216
+ try {
217
+ for (let i = 0; i < 3; i++) {
218
+ const response = await request(`http://127.0.0.1:${server.address().port}/ping`);
219
+ assert.equal(response.status, 200);
220
+ }
221
+ } finally {
222
+ agent.close();
223
+ server.closeAllConnections?.();
224
+ await new Promise((resolve) => server.close(resolve));
225
+ await destination.close();
226
+ }
227
+ });
228
+
229
+ test("https destination with a private CA: trusted only via targetCa, never by disabling verification", needsOpenssl, async () => {
230
+ const destination = await listen(echo, certs.trusted);
231
+ const target = `https://localhost:${destination.port}`;
232
+
233
+ const untrusted = await setup({ target });
234
+ try {
235
+ const response = await request(`${untrusted.base}/secure`);
236
+ assert.equal(response.status, 502);
237
+ await waitFor(() => untrusted.agent.queued === 1);
238
+ await untrusted.agent.flush();
239
+ assert.match(untrusted.collector.events()[0].payload.code, /UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|SELF_SIGNED_CERT_IN_CHAIN/);
240
+ } finally {
241
+ await untrusted.close();
242
+ }
243
+
244
+ const trusted = await setup({ target, proxyOptions: { targetCa: certs.ca } });
245
+ try {
246
+ const response = await request(`${trusted.base}/secure`);
247
+ assert.equal(response.status, 200);
248
+ assert.equal(response.json.url, "/secure");
249
+ } finally {
250
+ await trusted.close();
251
+ await destination.close();
252
+ }
253
+ });
254
+
255
+ test("the destination CA and the Midline CA are independent", needsOpenssl, async () => {
256
+ const destination = await listen(echo, certs.trusted);
257
+ const collector = await startCollector({ tls: certs.selfSigned });
258
+ const agent = new MidlineAgent({ apiKey: "ak_proxy", endpoint: collector.url, flushIntervalMs: 60_000, onError: () => {} });
259
+ const server = await startMidlineProxy({ target: `https://localhost:${destination.port}`, targetCa: certs.ca, port: 0, agent });
260
+ try {
261
+ const response = await request(`http://127.0.0.1:${server.address().port}/x`);
262
+ assert.equal(response.status, 200, "destination trusted via targetCa");
263
+ await waitFor(() => agent.queued === 1);
264
+ await agent.flush();
265
+ assert.equal(collector.requests.length, 0, "targetCa did not make the self-signed Midline server trusted");
266
+ assert.equal(agent.queued, 1);
267
+ } finally {
268
+ agent.close();
269
+ server.closeAllConnections?.();
270
+ await new Promise((resolve) => server.close(resolve));
271
+ await collector.close();
272
+ await destination.close();
273
+ }
274
+ });
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const { Redactor, REDACTED } = require("../dist/redact");
6
+
7
+ const redactor = new Redactor(["card_number"], ["x-tenant-secret-ish"]);
8
+
9
+ test("sensitive headers are masked by name, others kept", () => {
10
+ const headers = redactor.headers({
11
+ Authorization: "Bearer abc.def.ghi",
12
+ cookie: "sid=1",
13
+ "Set-Cookie": ["a=1", "b=2"],
14
+ "x-api-key": "ak_123",
15
+ "api-key": "k",
16
+ "X-CSRF-Token": "t",
17
+ "proxy-authorization": "Basic Zm9vOmJhcg==",
18
+ "x-tenant-secret-ish": "custom",
19
+ "content-type": "application/json",
20
+ "x-request-id": "abc",
21
+ });
22
+ for (const name of ["authorization", "cookie", "set-cookie", "x-api-key", "api-key", "x-csrf-token", "proxy-authorization", "x-tenant-secret-ish"]) {
23
+ assert.equal(headers[name], REDACTED, name);
24
+ }
25
+ assert.equal(headers["content-type"], "application/json");
26
+ assert.equal(headers["x-request-id"], "abc");
27
+ });
28
+
29
+ test("keys are matched regardless of case and punctuation, at any depth", () => {
30
+ const out = redactor.value({
31
+ user: { name: "Ada", password: "hunter2", Access_Token: "x", refreshToken: "y", client_secret: "z" },
32
+ api_key: "k",
33
+ items: [{ cardNumber: "4242", card_number: "4242", cvv: "123" }],
34
+ author: "kept",
35
+ auth: "gone",
36
+ tokenCount: 5,
37
+ });
38
+ assert.equal(out.user.name, "Ada");
39
+ assert.equal(out.user.password, REDACTED);
40
+ assert.equal(out.user.Access_Token, REDACTED);
41
+ assert.equal(out.user.refreshToken, REDACTED);
42
+ assert.equal(out.user.client_secret, REDACTED);
43
+ assert.equal(out.api_key, REDACTED);
44
+ assert.equal(out.items[0].cardNumber, REDACTED);
45
+ assert.equal(out.items[0].card_number, REDACTED);
46
+ assert.equal(out.items[0].cvv, REDACTED);
47
+ assert.equal(out.author, "kept");
48
+ assert.equal(out.auth, REDACTED);
49
+ assert.equal(out.tokenCount, REDACTED, "errs on the side of masking");
50
+ });
51
+
52
+ test("credentials inside free text are masked", () => {
53
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.c2lnbmF0dXJlLXZhbHVl";
54
+ const text = [
55
+ "Authorization: Bearer abcdefghijklmnop",
56
+ `token ${jwt}`,
57
+ "key ak_2ada04001f975b0bbbca1d3370006b1bfdc47ec8272d92022afa969f2744046e",
58
+ "db mongodb+srv://admin:s3cret@cluster0.example.net/app",
59
+ "/callback?code=1&access_token=xyz&page=2",
60
+ "stripe sk_live_abcdefghijklmnop",
61
+ ].join("\n");
62
+ const out = redactor.string(text, 10_000);
63
+ assert.doesNotMatch(out, /abcdefghijklmnop/);
64
+ assert.doesNotMatch(out, /eyJhbGci/);
65
+ assert.doesNotMatch(out, /2ada04001f97/);
66
+ assert.doesNotMatch(out, /s3cret/);
67
+ assert.doesNotMatch(out, /xyz/);
68
+ assert.match(out, /page=2/);
69
+ assert.match(out, /cluster0\.example\.net/);
70
+ });
71
+
72
+ test("query strings: sensitive params masked, others kept", () => {
73
+ assert.deepEqual(redactor.query("page=2&token=abc&tag=a&tag=b"), { page: "2", token: REDACTED, tag: ["a", "b"] });
74
+ });
75
+
76
+ test("bodies: JSON parsed and redacted, truncated JSON still masked, binary omitted", () => {
77
+ const json = redactor.body(Buffer.from(JSON.stringify({ email: "a@b.c", password: "p" })), "application/json", 4096);
78
+ assert.deepEqual(json.body, { email: "a@b.c", password: REDACTED });
79
+
80
+ const cut = redactor.body('{"email":"a@b.c","password":"hunter2","note":"', "application/json", 4096);
81
+ assert.doesNotMatch(String(cut.body), /hunter2/);
82
+
83
+ const long = redactor.body({ data: "x".repeat(5000) }, "application/json", 100);
84
+ assert.equal(long.truncated, true);
85
+ assert.ok(Buffer.byteLength(long.body) <= 100);
86
+
87
+ const form = redactor.body("user=ada&password=p", "application/x-www-form-urlencoded", 4096);
88
+ assert.deepEqual(form.body, { user: "ada", password: REDACTED });
89
+
90
+ assert.deepEqual(redactor.body(Buffer.from([1, 2, 3]), "application/octet-stream", 4096), { omitted: "content-type application/octet-stream" });
91
+ });
92
+
93
+ test("hostile shapes are bounded: cycles, depth, width", () => {
94
+ const cyclic = { a: 1 };
95
+ cyclic.self = cyclic;
96
+ assert.equal(redactor.value(cyclic).self, "[Circular]");
97
+
98
+ let deep = {};
99
+ const root = deep;
100
+ for (let i = 0; i < 50; i++) deep = deep.next = {};
101
+ assert.match(JSON.stringify(redactor.value(root)), /\[Truncated\]/);
102
+
103
+ const wide = Object.fromEntries(Array.from({ length: 500 }, (_, i) => [`k${i}`, i]));
104
+ assert.ok(Object.keys(redactor.value(wide)).length <= 201);
105
+ });