midline-agent 0.1.8 → 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/README.md +288 -252
- package/dist/agent.d.ts +105 -6
- package/dist/agent.js +663 -102
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +230 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +38 -0
- package/dist/errorHandler.d.ts +11 -2
- package/dist/errorHandler.js +32 -12
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -1
- package/dist/middleware.d.ts +19 -2
- package/dist/middleware.js +92 -41
- package/dist/proxy.d.ts +70 -0
- package/dist/proxy.js +383 -0
- package/dist/redact.d.ts +35 -0
- package/dist/redact.js +223 -0
- package/dist/tap.d.ts +18 -0
- package/dist/tap.js +62 -0
- package/dist/transport.d.ts +35 -0
- package/dist/transport.js +133 -0
- package/dist/types.d.ts +107 -5
- package/package.json +13 -8
- package/src/agent.ts +734 -102
- package/src/cli.ts +69 -0
- package/src/config.ts +232 -0
- package/src/context.ts +48 -0
- package/src/errorHandler.ts +36 -13
- package/src/index.ts +19 -2
- package/src/middleware.ts +118 -43
- package/src/proxy.ts +435 -0
- package/src/redact.ts +231 -0
- package/src/tap.ts +55 -0
- package/src/transport.ts +125 -0
- package/src/types.ts +144 -31
- package/test/agent.test.js +353 -0
- package/test/helpers.js +151 -0
- package/test/middleware.test.js +178 -0
- package/test/proxy.test.js +274 -0
- package/test/redact.test.js +105 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const tls = require("tls");
|
|
6
|
+
const { MidlineAgent } = require("../dist");
|
|
7
|
+
const { ConfigError, loadCa, resolveIngestUrl, trustStore } = require("../dist/config");
|
|
8
|
+
const { makeCerts, startCollector, closedPort, diagnostics } = require("./helpers");
|
|
9
|
+
|
|
10
|
+
const certs = makeCerts();
|
|
11
|
+
const needsOpenssl = certs ? {} : { skip: "openssl not available" };
|
|
12
|
+
|
|
13
|
+
function agentFor(endpoint, extra = {}) {
|
|
14
|
+
return new MidlineAgent({
|
|
15
|
+
apiKey: "ak_test_key",
|
|
16
|
+
serviceName: "agent-test",
|
|
17
|
+
endpoint,
|
|
18
|
+
flushIntervalMs: 60_000,
|
|
19
|
+
timeoutMs: 2000,
|
|
20
|
+
connectTimeoutMs: 1000,
|
|
21
|
+
...extra,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("endpoint: a base URL gets the ingest path, a full URL is kept", () => {
|
|
26
|
+
assert.equal(resolveIngestUrl("https://api.usemidline.com").href, "https://api.usemidline.com/api/api-monitor/events");
|
|
27
|
+
assert.equal(resolveIngestUrl("https://api.usemidline.com/").href, "https://api.usemidline.com/api/api-monitor/events");
|
|
28
|
+
assert.equal(
|
|
29
|
+
resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events").href,
|
|
30
|
+
"https://api.usemidline.com/api/api-monitor/events",
|
|
31
|
+
);
|
|
32
|
+
assert.equal(
|
|
33
|
+
resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events/batch").href,
|
|
34
|
+
"https://api.usemidline.com/api/api-monitor/events",
|
|
35
|
+
);
|
|
36
|
+
assert.equal(resolveIngestUrl("https://gw.internal/midline").href, "https://gw.internal/midline/api/api-monitor/events");
|
|
37
|
+
assert.equal(resolveIngestUrl("http://localhost:8000").href, "http://localhost:8000/api/api-monitor/events");
|
|
38
|
+
assert.equal(resolveIngestUrl("http://127.0.0.1:8000").href, "http://127.0.0.1:8000/api/api-monitor/events");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("endpoint: plain http to a non-loopback host is refused, so the key never travels in cleartext", () => {
|
|
42
|
+
assert.throws(() => resolveIngestUrl("http://api.usemidline.com"), ConfigError);
|
|
43
|
+
assert.throws(() => resolveIngestUrl("http://10.0.0.5:8000"), /cleartext/);
|
|
44
|
+
assert.throws(() => resolveIngestUrl("ftp://api.usemidline.com"), ConfigError);
|
|
45
|
+
assert.throws(() => resolveIngestUrl("https://user:pass@api.usemidline.com"), /credentials/);
|
|
46
|
+
|
|
47
|
+
const { messages, onError } = diagnostics();
|
|
48
|
+
const agent = new MidlineAgent({ apiKey: "ak_x", endpoint: "http://api.usemidline.com", onError });
|
|
49
|
+
assert.equal(agent.active, false);
|
|
50
|
+
assert.match(messages[0], /cleartext/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("agent without an API key stays off and says so once", () => {
|
|
54
|
+
const { messages, onError } = diagnostics();
|
|
55
|
+
const previous = process.env.MIDLINE_API_KEY;
|
|
56
|
+
delete process.env.MIDLINE_API_KEY;
|
|
57
|
+
const agent = new MidlineAgent({ onError });
|
|
58
|
+
assert.equal(agent.active, false);
|
|
59
|
+
agent.addEvent({ type: "request", path: "/" });
|
|
60
|
+
assert.equal(agent.queued, 0);
|
|
61
|
+
assert.equal(messages.length, 1);
|
|
62
|
+
if (previous !== undefined) process.env.MIDLINE_API_KEY = previous;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("custom CA: appended to the default trust store, bad input fails loudly", needsOpenssl, () => {
|
|
66
|
+
const extra = loadCa(certs.ca, "ca");
|
|
67
|
+
const store = trustStore(extra);
|
|
68
|
+
assert.ok(store.length > tls.rootCertificates.length, "public roots are kept");
|
|
69
|
+
assert.equal(trustStore(undefined), undefined, "no CA means Node's defaults, untouched");
|
|
70
|
+
assert.deepEqual(loadCa(certs.caPath, "ca")[0].toString(), certs.ca, "a file path works");
|
|
71
|
+
assert.throws(() => loadCa("/nope/ca.pem", "ca"), /cannot read CA file/);
|
|
72
|
+
assert.throws(() => loadCa("-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----", "ca"), /not a parseable/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("TLS: a self-signed Midline server is refused, events stay buffered, the app is unaffected", needsOpenssl, async () => {
|
|
76
|
+
const collector = await startCollector({ tls: certs.selfSigned });
|
|
77
|
+
const { messages, onError } = diagnostics();
|
|
78
|
+
const agent = agentFor(collector.url, { onError });
|
|
79
|
+
try {
|
|
80
|
+
agent.addEvent({ type: "request", path: "/hello", method: "GET", statusCode: 200 });
|
|
81
|
+
await agent.flush();
|
|
82
|
+
|
|
83
|
+
assert.equal(collector.requests.length, 0, "nothing was sent over an unverified connection");
|
|
84
|
+
assert.equal(agent.queued, 1, "the event is kept for retry");
|
|
85
|
+
assert.equal(agent.active, true, "a TLS fault does not permanently disable the agent");
|
|
86
|
+
assert.equal(messages.length, 1);
|
|
87
|
+
assert.match(messages[0], /DEPTH_ZERO_SELF_SIGNED_CERT/);
|
|
88
|
+
assert.match(messages[0], /verification stays on/);
|
|
89
|
+
} finally {
|
|
90
|
+
agent.close();
|
|
91
|
+
await collector.close();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("TLS: a private CA passed via `ca` is trusted for the Midline server", needsOpenssl, async () => {
|
|
96
|
+
const collector = await startCollector({ tls: certs.trusted });
|
|
97
|
+
const agent = agentFor(collector.url, { ca: certs.ca });
|
|
98
|
+
try {
|
|
99
|
+
agent.addEvent({ type: "request", path: "/hello", method: "GET", statusCode: 200 });
|
|
100
|
+
await agent.flush();
|
|
101
|
+
assert.equal(collector.requests.length, 1);
|
|
102
|
+
assert.equal(collector.requests[0].url, "/api/api-monitor/events/batch");
|
|
103
|
+
assert.equal(collector.requests[0].headers["x-api-key"], "ak_test_key");
|
|
104
|
+
assert.equal(agent.queued, 0);
|
|
105
|
+
} finally {
|
|
106
|
+
agent.close();
|
|
107
|
+
await collector.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("TLS: a trusted CA still does not excuse a certificate for the wrong hostname", needsOpenssl, async () => {
|
|
112
|
+
const collector = await startCollector({ tls: certs.wrongHost });
|
|
113
|
+
const { messages, onError } = diagnostics();
|
|
114
|
+
const agent = agentFor(collector.url, { ca: certs.ca, onError });
|
|
115
|
+
try {
|
|
116
|
+
agent.addEvent({ type: "request", path: "/hello" });
|
|
117
|
+
await agent.flush();
|
|
118
|
+
assert.equal(collector.requests.length, 0);
|
|
119
|
+
assert.match(messages[0], /ERR_TLS_CERT_ALTNAME_INVALID/);
|
|
120
|
+
} finally {
|
|
121
|
+
agent.close();
|
|
122
|
+
await collector.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("MIDLINE_CUSTOM_CA (file path) is honoured", needsOpenssl, async () => {
|
|
127
|
+
const collector = await startCollector({ tls: certs.trusted });
|
|
128
|
+
process.env.MIDLINE_CUSTOM_CA = certs.caPath;
|
|
129
|
+
const agent = agentFor(collector.url);
|
|
130
|
+
try {
|
|
131
|
+
agent.addEvent({ type: "request", path: "/env-ca" });
|
|
132
|
+
await agent.flush();
|
|
133
|
+
assert.equal(collector.requests.length, 1);
|
|
134
|
+
} finally {
|
|
135
|
+
delete process.env.MIDLINE_CUSTOM_CA;
|
|
136
|
+
agent.close();
|
|
137
|
+
await collector.close();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("wire format stays inside the long-standing ingest schema", async () => {
|
|
142
|
+
const collector = await startCollector();
|
|
143
|
+
const agent = agentFor(collector.url, { environment: "test", release: "v1" });
|
|
144
|
+
try {
|
|
145
|
+
agent.addEvent({
|
|
146
|
+
type: "error",
|
|
147
|
+
path: "/charges?token=abc123",
|
|
148
|
+
method: "post",
|
|
149
|
+
statusCode: 502,
|
|
150
|
+
message: "upstream said Bearer abcdefghijklmnop",
|
|
151
|
+
stack: "Error: boom\n at x",
|
|
152
|
+
requestId: "req-1",
|
|
153
|
+
correlationId: "corr-1",
|
|
154
|
+
});
|
|
155
|
+
await agent.flush();
|
|
156
|
+
|
|
157
|
+
const [event] = collector.events();
|
|
158
|
+
const allowed = new Set([
|
|
159
|
+
"apiKey", "eventType", "severity", "category", "route", "method", "statusCode", "responseTime", "payload",
|
|
160
|
+
"timestamp", "ip", "userAgent", "metadata", "environment", "service", "host", "region", "traceId", "spanId",
|
|
161
|
+
"threatDetected", "ruleId", "release",
|
|
162
|
+
]);
|
|
163
|
+
for (const key of Object.keys(event)) {
|
|
164
|
+
assert.ok(allowed.has(key), `unexpected top-level field ${key} would be rejected by older servers`);
|
|
165
|
+
}
|
|
166
|
+
assert.equal(event.route, "/charges", "query strings never go in the route");
|
|
167
|
+
assert.equal(event.method, "POST");
|
|
168
|
+
assert.equal(event.eventType, "error");
|
|
169
|
+
assert.equal(event.payload.error, "upstream said Bearer [REDACTED]");
|
|
170
|
+
assert.equal(event.metadata.requestId, "req-1");
|
|
171
|
+
assert.equal(event.metadata.correlationId, "corr-1");
|
|
172
|
+
assert.equal(event.environment, "test");
|
|
173
|
+
} finally {
|
|
174
|
+
agent.close();
|
|
175
|
+
await collector.close();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("5xx and 429 are retried with backoff and keep the events", async () => {
|
|
180
|
+
const collector = await startCollector({
|
|
181
|
+
respond: () => ({ status: 503, body: { message: "down" }, headers: { "retry-after": "1" } }),
|
|
182
|
+
});
|
|
183
|
+
const { messages, onError } = diagnostics();
|
|
184
|
+
const agent = agentFor(collector.url, { onError });
|
|
185
|
+
try {
|
|
186
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
187
|
+
agent.addEvent({ type: "request", path: "/b" });
|
|
188
|
+
await agent.flush();
|
|
189
|
+
assert.equal(agent.queued, 2);
|
|
190
|
+
assert.equal(collector.requests.length, 1, "one attempt per drain, not one per event");
|
|
191
|
+
|
|
192
|
+
collector.setResponder(() => ({ status: 201, body: { success: 2, failed: 0 } }));
|
|
193
|
+
await agent.flush();
|
|
194
|
+
assert.equal(agent.queued, 0);
|
|
195
|
+
assert.match(messages.at(-1), /recovered/);
|
|
196
|
+
} finally {
|
|
197
|
+
agent.close();
|
|
198
|
+
await collector.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("401 turns the agent off instead of retrying forever", async () => {
|
|
203
|
+
const collector = await startCollector({ respond: () => ({ status: 401, body: { message: "Invalid API key" } }) });
|
|
204
|
+
const { onError } = diagnostics();
|
|
205
|
+
const agent = agentFor(collector.url, { onError });
|
|
206
|
+
try {
|
|
207
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
208
|
+
await agent.flush();
|
|
209
|
+
assert.equal(agent.active, false);
|
|
210
|
+
agent.addEvent({ type: "request", path: "/b" });
|
|
211
|
+
assert.equal(agent.queued, 0);
|
|
212
|
+
} finally {
|
|
213
|
+
agent.close();
|
|
214
|
+
await collector.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("an older server's 201 with every event failed is treated as a rejected key", async () => {
|
|
219
|
+
const collector = await startCollector({ respond: (req) => ({ status: 201, body: { success: 0, failed: req.body.events.length } }) });
|
|
220
|
+
const { onError } = diagnostics();
|
|
221
|
+
const agent = agentFor(collector.url, { onError });
|
|
222
|
+
try {
|
|
223
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
224
|
+
await agent.flush();
|
|
225
|
+
assert.equal(agent.active, false);
|
|
226
|
+
} finally {
|
|
227
|
+
agent.close();
|
|
228
|
+
await collector.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("one malformed event costs only itself: a 400 batch is re-sent one by one", async () => {
|
|
233
|
+
const collector = await startCollector({
|
|
234
|
+
respond: (req) => {
|
|
235
|
+
const events = req.body.events;
|
|
236
|
+
if (events.some((event) => event.route === "/bad")) {
|
|
237
|
+
return { status: 400, body: { message: ["route must be shorter"] } };
|
|
238
|
+
}
|
|
239
|
+
return { status: 201, body: { success: events.length, failed: 0 } };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const { messages, onError } = diagnostics();
|
|
243
|
+
const agent = agentFor(collector.url, { onError });
|
|
244
|
+
try {
|
|
245
|
+
agent.addEvent({ type: "request", path: "/ok-1" });
|
|
246
|
+
agent.addEvent({ type: "request", path: "/bad" });
|
|
247
|
+
agent.addEvent({ type: "request", path: "/ok-2" });
|
|
248
|
+
await agent.flush();
|
|
249
|
+
|
|
250
|
+
const accepted = collector.requests
|
|
251
|
+
.filter((request) => !request.body.events.some((event) => event.route === "/bad"))
|
|
252
|
+
.flatMap((request) => request.body.events.map((event) => event.route));
|
|
253
|
+
assert.deepEqual(accepted, ["/ok-1", "/ok-2"]);
|
|
254
|
+
assert.equal(agent.queued, 0);
|
|
255
|
+
assert.match(messages.join("\n"), /route must be shorter/);
|
|
256
|
+
} finally {
|
|
257
|
+
agent.close();
|
|
258
|
+
await collector.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("413 halves the batch instead of dropping it", async () => {
|
|
263
|
+
const collector = await startCollector({
|
|
264
|
+
respond: (req) =>
|
|
265
|
+
req.body.events.length > 1 ? { status: 413, body: {} } : { status: 201, body: { success: 1, failed: 0 } },
|
|
266
|
+
});
|
|
267
|
+
const agent = agentFor(collector.url, { onError: () => {} });
|
|
268
|
+
try {
|
|
269
|
+
for (let i = 0; i < 4; i++) agent.addEvent({ type: "request", path: `/e${i}` });
|
|
270
|
+
await agent.flush();
|
|
271
|
+
assert.equal(agent.queued, 0);
|
|
272
|
+
const accepted = collector.requests.filter((request) => request.body.events.length === 1);
|
|
273
|
+
assert.deepEqual(accepted.map((request) => request.body.events[0].route), ["/e0", "/e1", "/e2", "/e3"]);
|
|
274
|
+
} finally {
|
|
275
|
+
agent.close();
|
|
276
|
+
await collector.close();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("unreachable Midline server: bounded queue, oldest dropped, nothing thrown", async () => {
|
|
281
|
+
const port = await closedPort();
|
|
282
|
+
const { messages, onError } = diagnostics();
|
|
283
|
+
const agent = agentFor(`http://127.0.0.1:${port}`, { onError, maxQueueSize: 3 });
|
|
284
|
+
try {
|
|
285
|
+
for (let i = 0; i < 10; i++) {
|
|
286
|
+
assert.doesNotThrow(() => agent.addEvent({ type: "request", path: `/e${i}` }));
|
|
287
|
+
}
|
|
288
|
+
assert.equal(agent.queued, 3);
|
|
289
|
+
await agent.flush();
|
|
290
|
+
await agent.flush();
|
|
291
|
+
assert.equal(agent.queued, 3);
|
|
292
|
+
assert.equal(messages.filter((message) => /ECONNREFUSED|refused/.test(message)).length, 1, "one line per distinct fault");
|
|
293
|
+
} finally {
|
|
294
|
+
agent.close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("timeouts: a server that never answers does not stall delivery", async () => {
|
|
299
|
+
const http = require("http");
|
|
300
|
+
const sockets = new Set();
|
|
301
|
+
const server = http.createServer(() => {});
|
|
302
|
+
server.on("connection", (socket) => sockets.add(socket));
|
|
303
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
304
|
+
const { messages, onError } = diagnostics();
|
|
305
|
+
const agent = agentFor(`http://127.0.0.1:${server.address().port}`, { onError, timeoutMs: 200 });
|
|
306
|
+
try {
|
|
307
|
+
agent.addEvent({ type: "request", path: "/slow" });
|
|
308
|
+
const started = Date.now();
|
|
309
|
+
await agent.flush();
|
|
310
|
+
assert.ok(Date.now() - started < 1500);
|
|
311
|
+
assert.equal(agent.queued, 1);
|
|
312
|
+
assert.match(messages[0], /did not answer within 200ms/);
|
|
313
|
+
} finally {
|
|
314
|
+
agent.close();
|
|
315
|
+
for (const socket of sockets) socket.destroy();
|
|
316
|
+
await new Promise((resolve) => server.close(resolve));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("oversized events lose their captured bodies before they are dropped", async () => {
|
|
321
|
+
const collector = await startCollector();
|
|
322
|
+
const agent = agentFor(collector.url, { maxEventBytes: 2048 });
|
|
323
|
+
try {
|
|
324
|
+
agent.addEvent({
|
|
325
|
+
type: "request",
|
|
326
|
+
path: "/big",
|
|
327
|
+
response: { body: "x".repeat(10_000) },
|
|
328
|
+
});
|
|
329
|
+
await agent.flush();
|
|
330
|
+
const [event] = collector.events();
|
|
331
|
+
assert.ok(event, "event still delivered");
|
|
332
|
+
assert.equal(event.payload.response.body, undefined);
|
|
333
|
+
assert.equal(event.payload.response.omitted, "event size limit");
|
|
334
|
+
} finally {
|
|
335
|
+
agent.close();
|
|
336
|
+
await collector.close();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("static facade keeps working for existing integrations", async () => {
|
|
341
|
+
const collector = await startCollector();
|
|
342
|
+
try {
|
|
343
|
+
const agent = MidlineAgent.init({ apiKey: "ak_static", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
344
|
+
assert.equal(MidlineAgent.current, agent);
|
|
345
|
+
MidlineAgent.addEvent({ type: "request", path: "/static" });
|
|
346
|
+
await MidlineAgent.flush();
|
|
347
|
+
assert.equal(collector.events()[0].route, "/static");
|
|
348
|
+
await MidlineAgent.shutdown(500);
|
|
349
|
+
assert.equal(agent.active, false);
|
|
350
|
+
} finally {
|
|
351
|
+
await collector.close();
|
|
352
|
+
}
|
|
353
|
+
});
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const https = require("https");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mints a throwaway CA, a leaf signed by it for localhost/127.0.0.1, a leaf signed
|
|
12
|
+
* by it for the wrong hostname, and a self-signed leaf. Nothing is committed to the
|
|
13
|
+
* repo; the directory is removed when the process exits.
|
|
14
|
+
*/
|
|
15
|
+
function makeCerts() {
|
|
16
|
+
try {
|
|
17
|
+
execFileSync("openssl", ["version"], { stdio: "ignore" });
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "midline-agent-test-"));
|
|
23
|
+
process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true }));
|
|
24
|
+
const run = (args) => execFileSync("openssl", args, { cwd: dir, stdio: "pipe" });
|
|
25
|
+
const read = (name) => fs.readFileSync(path.join(dir, name), "utf8");
|
|
26
|
+
|
|
27
|
+
run(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=Midline Test CA"]);
|
|
28
|
+
|
|
29
|
+
const leaf = (name, san) => {
|
|
30
|
+
fs.writeFileSync(path.join(dir, `${name}.ext`), `subjectAltName=${san}\n`);
|
|
31
|
+
run(["req", "-newkey", "rsa:2048", "-nodes", "-keyout", `${name}.key`, "-out", `${name}.csr`, "-subj", `/CN=${name}`]);
|
|
32
|
+
run(["x509", "-req", "-in", `${name}.csr`, "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", `${name}.pem`, "-days", "2", "-extfile", `${name}.ext`]);
|
|
33
|
+
return { key: read(`${name}.key`), cert: read(`${name}.pem`) };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
run(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "self.key", "-out", "self.pem", "-days", "2", "-subj", "/CN=localhost"]);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
dir,
|
|
40
|
+
ca: read("ca.pem"),
|
|
41
|
+
caPath: path.join(dir, "ca.pem"),
|
|
42
|
+
trusted: leaf("localhost", "DNS:localhost,IP:127.0.0.1"),
|
|
43
|
+
wrongHost: leaf("wrong", "DNS:wrong.example"),
|
|
44
|
+
selfSigned: { key: read("self.key"), cert: read("self.pem") },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fake Midline server that records what it receives and answers with `respond`. */
|
|
49
|
+
async function startCollector({ tls, respond } = {}) {
|
|
50
|
+
const requests = [];
|
|
51
|
+
let responder = respond || (() => ({ status: 201, body: { success: 1, failed: 0 } }));
|
|
52
|
+
|
|
53
|
+
const handler = (req, res) => {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
56
|
+
req.on("end", () => {
|
|
57
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
body = raw;
|
|
63
|
+
}
|
|
64
|
+
const record = { method: req.method, url: req.url, headers: req.headers, body };
|
|
65
|
+
requests.push(record);
|
|
66
|
+
const { status, body: responseBody, headers } = responder(record, requests.length);
|
|
67
|
+
res.writeHead(status, { "content-type": "application/json", ...(headers || {}) });
|
|
68
|
+
res.end(responseBody === undefined ? "" : JSON.stringify(responseBody));
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const server = tls ? https.createServer(tls, handler) : http.createServer(handler);
|
|
73
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
74
|
+
const { port } = server.address();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
url: `${tls ? "https" : "http"}://${tls ? "localhost" : "127.0.0.1"}:${port}`,
|
|
78
|
+
ipUrl: `${tls ? "https" : "http"}://127.0.0.1:${port}`,
|
|
79
|
+
port,
|
|
80
|
+
requests,
|
|
81
|
+
events: () => requests.flatMap((request) => (request.body && request.body.events) || []),
|
|
82
|
+
setResponder: (fn) => {
|
|
83
|
+
responder = fn;
|
|
84
|
+
},
|
|
85
|
+
close: () =>
|
|
86
|
+
new Promise((resolve) => {
|
|
87
|
+
server.closeAllConnections?.();
|
|
88
|
+
server.close(() => resolve());
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Starts any request listener on an ephemeral loopback port. */
|
|
94
|
+
async function listen(listener, tls) {
|
|
95
|
+
const server = tls ? https.createServer(tls, listener) : http.createServer(listener);
|
|
96
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
97
|
+
return {
|
|
98
|
+
server,
|
|
99
|
+
port: server.address().port,
|
|
100
|
+
close: () =>
|
|
101
|
+
new Promise((resolve) => {
|
|
102
|
+
server.closeAllConnections?.();
|
|
103
|
+
server.close(() => resolve());
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function request(url, { method = "GET", headers = {}, body } = {}) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const req = http.request(url, { method, headers, agent: false }, (res) => {
|
|
111
|
+
const chunks = [];
|
|
112
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
113
|
+
res.on("end", () => {
|
|
114
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
let json;
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text);
|
|
118
|
+
} catch {
|
|
119
|
+
json = undefined;
|
|
120
|
+
}
|
|
121
|
+
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
req.on("error", reject);
|
|
125
|
+
if (body !== undefined) req.write(body);
|
|
126
|
+
req.end();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A port that was free a moment ago and has nothing listening on it now. */
|
|
131
|
+
async function closedPort() {
|
|
132
|
+
const { port, close } = await listen(() => {});
|
|
133
|
+
await close();
|
|
134
|
+
return port;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function diagnostics() {
|
|
138
|
+
const messages = [];
|
|
139
|
+
return { messages, onError: (message) => messages.push(message) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function waitFor(predicate, timeoutMs = 3000) {
|
|
143
|
+
const deadline = Date.now() + timeoutMs;
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
if (await predicate()) return true;
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { makeCerts, startCollector, listen, request, closedPort, diagnostics, waitFor };
|
|
@@ -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
|
+
});
|