brakit 0.6.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 +21 -0
- package/README.md +189 -0
- package/dist/bin/brakit.d.ts +2 -0
- package/dist/bin/brakit.js +5679 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +1603 -0
- package/dist/instrument/preload.d.ts +2 -0
- package/dist/instrument/preload.js +739 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
// src/proxy/server.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
|
|
4
|
+
// src/proxy/handler.ts
|
|
5
|
+
import {
|
|
6
|
+
request as httpRequest
|
|
7
|
+
} from "http";
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
|
|
10
|
+
// src/constants/routes.ts
|
|
11
|
+
var DASHBOARD_PREFIX = "/__brakit";
|
|
12
|
+
|
|
13
|
+
// src/constants/limits.ts
|
|
14
|
+
var MAX_REQUEST_ENTRIES = 1e3;
|
|
15
|
+
var MAX_TELEMETRY_ENTRIES = 1e3;
|
|
16
|
+
|
|
17
|
+
// src/constants/thresholds.ts
|
|
18
|
+
var FLOW_GAP_MS = 5e3;
|
|
19
|
+
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
20
|
+
var MIN_POLLING_SEQUENCE = 3;
|
|
21
|
+
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
22
|
+
var N1_QUERY_THRESHOLD = 5;
|
|
23
|
+
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
24
|
+
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
25
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
26
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
27
|
+
var AUTH_OVERHEAD_PCT = 30;
|
|
28
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
29
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
30
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
31
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
32
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
33
|
+
var HIGH_ROW_COUNT = 100;
|
|
34
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
35
|
+
|
|
36
|
+
// src/constants/headers.ts
|
|
37
|
+
var BRAKIT_REQUEST_ID_HEADER = "x-brakit-request-id";
|
|
38
|
+
|
|
39
|
+
// src/proxy/static-patterns.ts
|
|
40
|
+
var STATIC_PATTERNS = [
|
|
41
|
+
/^\/_next\//,
|
|
42
|
+
/\.(?:js|css|map|ico|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot)$/,
|
|
43
|
+
/^\/favicon/,
|
|
44
|
+
/^\/__nextjs/
|
|
45
|
+
];
|
|
46
|
+
function isStaticPath(urlPath) {
|
|
47
|
+
return STATIC_PATTERNS.some((p) => p.test(urlPath));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/store/request-store.ts
|
|
51
|
+
function flattenHeaders(headers) {
|
|
52
|
+
const flat = {};
|
|
53
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
54
|
+
if (value === void 0) continue;
|
|
55
|
+
flat[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
56
|
+
}
|
|
57
|
+
return flat;
|
|
58
|
+
}
|
|
59
|
+
var RequestStore = class {
|
|
60
|
+
constructor(maxEntries = MAX_REQUEST_ENTRIES) {
|
|
61
|
+
this.maxEntries = maxEntries;
|
|
62
|
+
}
|
|
63
|
+
requests = [];
|
|
64
|
+
listeners = [];
|
|
65
|
+
capture(input) {
|
|
66
|
+
const url = input.url;
|
|
67
|
+
const path = url.split("?")[0];
|
|
68
|
+
let requestBodyStr = null;
|
|
69
|
+
if (input.requestBody && input.requestBody.length > 0) {
|
|
70
|
+
requestBodyStr = input.requestBody.subarray(0, input.config.maxBodyCapture).toString("utf-8");
|
|
71
|
+
}
|
|
72
|
+
let responseBodyStr = null;
|
|
73
|
+
if (input.responseBody && input.responseBody.length > 0) {
|
|
74
|
+
const ct = input.responseContentType;
|
|
75
|
+
if (ct.includes("json") || ct.includes("text") || ct.includes("html")) {
|
|
76
|
+
responseBodyStr = input.responseBody.subarray(0, input.config.maxBodyCapture).toString("utf-8");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const entry = {
|
|
80
|
+
id: input.requestId,
|
|
81
|
+
method: input.method,
|
|
82
|
+
url,
|
|
83
|
+
path,
|
|
84
|
+
headers: flattenHeaders(input.requestHeaders),
|
|
85
|
+
requestBody: requestBodyStr,
|
|
86
|
+
statusCode: input.statusCode,
|
|
87
|
+
responseHeaders: flattenHeaders(input.responseHeaders),
|
|
88
|
+
responseBody: responseBodyStr,
|
|
89
|
+
startedAt: input.startTime,
|
|
90
|
+
durationMs: Math.round(performance.now() - input.startTime),
|
|
91
|
+
responseSize: input.responseBody?.length ?? 0,
|
|
92
|
+
isStatic: isStaticPath(path)
|
|
93
|
+
};
|
|
94
|
+
this.requests.push(entry);
|
|
95
|
+
if (this.requests.length > this.maxEntries) {
|
|
96
|
+
this.requests.shift();
|
|
97
|
+
}
|
|
98
|
+
for (const fn of this.listeners) {
|
|
99
|
+
fn(entry);
|
|
100
|
+
}
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
getAll() {
|
|
104
|
+
return this.requests;
|
|
105
|
+
}
|
|
106
|
+
clear() {
|
|
107
|
+
this.requests.length = 0;
|
|
108
|
+
}
|
|
109
|
+
onRequest(fn) {
|
|
110
|
+
this.listeners.push(fn);
|
|
111
|
+
}
|
|
112
|
+
offRequest(fn) {
|
|
113
|
+
const idx = this.listeners.indexOf(fn);
|
|
114
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/proxy/request-log.ts
|
|
119
|
+
var defaultStore = new RequestStore();
|
|
120
|
+
var captureRequest = (input) => defaultStore.capture(input);
|
|
121
|
+
var getRequests = () => defaultStore.getAll();
|
|
122
|
+
var onRequest = (fn) => defaultStore.onRequest(fn);
|
|
123
|
+
var offRequest = (fn) => defaultStore.offRequest(fn);
|
|
124
|
+
|
|
125
|
+
// src/proxy/handler.ts
|
|
126
|
+
function proxyRequest(clientReq, clientRes, config) {
|
|
127
|
+
const startTime = performance.now();
|
|
128
|
+
const method = clientReq.method ?? "GET";
|
|
129
|
+
const requestId = randomUUID();
|
|
130
|
+
const shouldCaptureBody = method !== "GET" && method !== "HEAD";
|
|
131
|
+
const bodyChunks = [];
|
|
132
|
+
let bodySize = 0;
|
|
133
|
+
if (shouldCaptureBody) {
|
|
134
|
+
clientReq.on("data", (chunk) => {
|
|
135
|
+
if (bodySize < config.maxBodyCapture) {
|
|
136
|
+
bodyChunks.push(chunk);
|
|
137
|
+
bodySize += chunk.length;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const proxyHeaders = { ...clientReq.headers };
|
|
142
|
+
proxyHeaders["accept-encoding"] = "identity";
|
|
143
|
+
proxyHeaders[BRAKIT_REQUEST_ID_HEADER] = requestId;
|
|
144
|
+
const proxyReq = httpRequest(
|
|
145
|
+
{
|
|
146
|
+
hostname: "127.0.0.1",
|
|
147
|
+
port: config.targetPort,
|
|
148
|
+
path: clientReq.url,
|
|
149
|
+
method,
|
|
150
|
+
headers: proxyHeaders
|
|
151
|
+
},
|
|
152
|
+
(proxyRes) => {
|
|
153
|
+
handleProxyResponse(
|
|
154
|
+
clientReq,
|
|
155
|
+
clientRes,
|
|
156
|
+
proxyRes,
|
|
157
|
+
startTime,
|
|
158
|
+
shouldCaptureBody ? bodyChunks : [],
|
|
159
|
+
config,
|
|
160
|
+
requestId
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
proxyReq.on("error", (err) => {
|
|
165
|
+
if (clientRes.headersSent) return;
|
|
166
|
+
const code = err.code;
|
|
167
|
+
if (code === "ECONNREFUSED" || code === "ECONNRESET") {
|
|
168
|
+
clientRes.writeHead(502, { "content-type": "text/html" });
|
|
169
|
+
clientRes.end(
|
|
170
|
+
`<html><body style="font-family:system-ui;padding:40px;text-align:center"><h2>brakit</h2><p>Waiting for dev server on port ${config.targetPort}...</p><script>setTimeout(()=>location.reload(),2000)</script></body></html>`
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
clientRes.writeHead(502, { "content-type": "text/plain" });
|
|
174
|
+
clientRes.end(`brakit proxy error: ${err.message}
|
|
175
|
+
`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
clientReq.pipe(proxyReq);
|
|
179
|
+
}
|
|
180
|
+
function handleProxyResponse(clientReq, clientRes, proxyRes, startTime, bodyChunks, config, requestId) {
|
|
181
|
+
const responseChunks = [];
|
|
182
|
+
let responseSize = 0;
|
|
183
|
+
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
184
|
+
proxyRes.on("data", (chunk) => {
|
|
185
|
+
clientRes.write(chunk);
|
|
186
|
+
if (responseSize < config.maxBodyCapture) {
|
|
187
|
+
responseChunks.push(chunk);
|
|
188
|
+
responseSize += chunk.length;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
proxyRes.on("end", () => {
|
|
192
|
+
clientRes.end();
|
|
193
|
+
const requestBody = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : null;
|
|
194
|
+
const responseBody = responseChunks.length > 0 ? Buffer.concat(responseChunks) : null;
|
|
195
|
+
captureRequest({
|
|
196
|
+
requestId,
|
|
197
|
+
method: clientReq.method ?? "GET",
|
|
198
|
+
url: clientReq.url ?? "/",
|
|
199
|
+
requestHeaders: clientReq.headers,
|
|
200
|
+
requestBody,
|
|
201
|
+
statusCode: proxyRes.statusCode ?? 0,
|
|
202
|
+
responseHeaders: proxyRes.headers,
|
|
203
|
+
responseBody,
|
|
204
|
+
responseContentType: proxyRes.headers["content-type"] ?? "",
|
|
205
|
+
startTime,
|
|
206
|
+
config
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
proxyRes.on("error", () => {
|
|
210
|
+
clientRes.end();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/proxy/websocket.ts
|
|
215
|
+
import {
|
|
216
|
+
request as httpRequest2
|
|
217
|
+
} from "http";
|
|
218
|
+
function handleUpgrade(req, clientSocket, head, targetPort) {
|
|
219
|
+
const targetReq = httpRequest2({
|
|
220
|
+
hostname: "127.0.0.1",
|
|
221
|
+
port: targetPort,
|
|
222
|
+
path: req.url,
|
|
223
|
+
method: req.method,
|
|
224
|
+
headers: req.headers
|
|
225
|
+
});
|
|
226
|
+
targetReq.on("upgrade", (_targetRes, targetSocket, targetHead) => {
|
|
227
|
+
const statusLine = `HTTP/1.1 101 Switching Protocols`;
|
|
228
|
+
const headerLines = [statusLine];
|
|
229
|
+
if (_targetRes.headers) {
|
|
230
|
+
for (const [key, value] of Object.entries(_targetRes.headers)) {
|
|
231
|
+
if (value === void 0) continue;
|
|
232
|
+
const vals = Array.isArray(value) ? value : [value];
|
|
233
|
+
for (const v of vals) {
|
|
234
|
+
headerLines.push(`${key}: ${v}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
headerLines.push("", "");
|
|
239
|
+
clientSocket.write(headerLines.join("\r\n"));
|
|
240
|
+
if (targetHead.length > 0) {
|
|
241
|
+
clientSocket.write(targetHead);
|
|
242
|
+
}
|
|
243
|
+
targetSocket.pipe(clientSocket);
|
|
244
|
+
clientSocket.pipe(targetSocket);
|
|
245
|
+
targetSocket.on("error", () => clientSocket.destroy());
|
|
246
|
+
clientSocket.on("error", () => targetSocket.destroy());
|
|
247
|
+
});
|
|
248
|
+
targetReq.on("error", () => {
|
|
249
|
+
clientSocket.destroy();
|
|
250
|
+
});
|
|
251
|
+
targetReq.write(head);
|
|
252
|
+
targetReq.end();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/analysis/group.ts
|
|
256
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
257
|
+
|
|
258
|
+
// src/analysis/categorize.ts
|
|
259
|
+
function detectCategory(req) {
|
|
260
|
+
const { method, url, statusCode, responseHeaders } = req;
|
|
261
|
+
if (req.isStatic) return "static";
|
|
262
|
+
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
263
|
+
return "auth-handshake";
|
|
264
|
+
}
|
|
265
|
+
const effectivePath = getEffectivePath(req);
|
|
266
|
+
if (/^\/api\/auth/i.test(effectivePath) || /^\/(api\/)?clerk/i.test(effectivePath)) {
|
|
267
|
+
return "auth-check";
|
|
268
|
+
}
|
|
269
|
+
if (method === "POST" && !effectivePath.startsWith("/api/")) {
|
|
270
|
+
return "server-action";
|
|
271
|
+
}
|
|
272
|
+
if (effectivePath.startsWith("/api/") && method !== "GET" && method !== "HEAD") {
|
|
273
|
+
return "api-call";
|
|
274
|
+
}
|
|
275
|
+
if (effectivePath.startsWith("/api/") && method === "GET") {
|
|
276
|
+
return "data-fetch";
|
|
277
|
+
}
|
|
278
|
+
if (url.includes("_rsc=")) {
|
|
279
|
+
return "navigation";
|
|
280
|
+
}
|
|
281
|
+
if (responseHeaders["x-middleware-rewrite"]) {
|
|
282
|
+
return "middleware";
|
|
283
|
+
}
|
|
284
|
+
if (method === "GET") {
|
|
285
|
+
const ct = responseHeaders["content-type"] ?? "";
|
|
286
|
+
if (ct.includes("text/html")) return "page-load";
|
|
287
|
+
}
|
|
288
|
+
return "unknown";
|
|
289
|
+
}
|
|
290
|
+
function getEffectivePath(req) {
|
|
291
|
+
const rewrite = req.responseHeaders["x-middleware-rewrite"];
|
|
292
|
+
if (!rewrite) return req.path;
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(rewrite, "http://localhost");
|
|
295
|
+
return url.pathname;
|
|
296
|
+
} catch {
|
|
297
|
+
return rewrite.startsWith("/") ? rewrite : req.path;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/analysis/label.ts
|
|
302
|
+
function extractSourcePage(req) {
|
|
303
|
+
const referer = req.headers["referer"] ?? req.headers["Referer"];
|
|
304
|
+
if (!referer) return void 0;
|
|
305
|
+
try {
|
|
306
|
+
const url = new URL(referer);
|
|
307
|
+
return url.pathname;
|
|
308
|
+
} catch {
|
|
309
|
+
return void 0;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function labelRequest(req) {
|
|
313
|
+
const category = detectCategory(req);
|
|
314
|
+
const label = generateHumanLabel(req, category);
|
|
315
|
+
const sourcePage = extractSourcePage(req);
|
|
316
|
+
return { ...req, category, label, sourcePage };
|
|
317
|
+
}
|
|
318
|
+
function generateHumanLabel(req, category) {
|
|
319
|
+
const effectivePath = getEffectivePath(req);
|
|
320
|
+
const endpointName = getEndpointName(effectivePath);
|
|
321
|
+
const failed = req.statusCode >= 400;
|
|
322
|
+
switch (category) {
|
|
323
|
+
case "auth-handshake":
|
|
324
|
+
return "Auth handshake";
|
|
325
|
+
case "auth-check":
|
|
326
|
+
return failed ? "Auth check failed" : "Checked auth";
|
|
327
|
+
case "middleware": {
|
|
328
|
+
const rewritePath = effectivePath !== req.path ? effectivePath : "";
|
|
329
|
+
return rewritePath ? `Redirected to ${rewritePath}` : "Middleware";
|
|
330
|
+
}
|
|
331
|
+
case "server-action": {
|
|
332
|
+
const name = prettifyEndpoint(req.path);
|
|
333
|
+
return failed ? `${name} failed` : name;
|
|
334
|
+
}
|
|
335
|
+
case "api-call": {
|
|
336
|
+
const action = deriveActionVerb(req.method, endpointName);
|
|
337
|
+
const name = prettifyEndpoint(endpointName);
|
|
338
|
+
if (failed) return `${action} ${name} failed`;
|
|
339
|
+
return `${action} ${name}`;
|
|
340
|
+
}
|
|
341
|
+
case "data-fetch": {
|
|
342
|
+
const name = prettifyEndpoint(endpointName);
|
|
343
|
+
if (failed) return `Failed to load ${name}`;
|
|
344
|
+
return `Loaded ${name}`;
|
|
345
|
+
}
|
|
346
|
+
case "page-load":
|
|
347
|
+
return failed ? "Page error" : "Loaded page";
|
|
348
|
+
case "navigation":
|
|
349
|
+
return "Navigated";
|
|
350
|
+
case "static":
|
|
351
|
+
return `Static: ${req.path.split("/").pop() ?? req.path}`;
|
|
352
|
+
default:
|
|
353
|
+
return failed ? `${req.method} ${req.path} failed` : `${req.method} ${req.path}`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function prettifyEndpoint(name) {
|
|
357
|
+
const cleaned = name.replace(/^\/api\//, "").replace(/\//g, " ").replace(/\.\.\./g, "").trim();
|
|
358
|
+
if (!cleaned) return "data";
|
|
359
|
+
return cleaned.split(" ").map((word) => {
|
|
360
|
+
if (word.endsWith("ses") || word.endsWith("us") || word.endsWith("ss"))
|
|
361
|
+
return word;
|
|
362
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
363
|
+
if (word.endsWith("s") && word.length > 3) return word.slice(0, -1);
|
|
364
|
+
return word;
|
|
365
|
+
}).join(" ");
|
|
366
|
+
}
|
|
367
|
+
function deriveActionVerb(method, endpointName) {
|
|
368
|
+
const lower = endpointName.toLowerCase();
|
|
369
|
+
const VERB_PATTERNS = [
|
|
370
|
+
[/enhance/, "Enhanced"],
|
|
371
|
+
[/generate/, "Generated"],
|
|
372
|
+
[/create/, "Created"],
|
|
373
|
+
[/update/, "Updated"],
|
|
374
|
+
[/delete|remove/, "Deleted"],
|
|
375
|
+
[/send/, "Sent"],
|
|
376
|
+
[/upload/, "Uploaded"],
|
|
377
|
+
[/save/, "Saved"],
|
|
378
|
+
[/submit/, "Submitted"],
|
|
379
|
+
[/login|signin/, "Logged in"],
|
|
380
|
+
[/logout|signout/, "Logged out"],
|
|
381
|
+
[/register|signup/, "Registered"]
|
|
382
|
+
];
|
|
383
|
+
for (const [pattern, verb] of VERB_PATTERNS) {
|
|
384
|
+
if (pattern.test(lower)) return verb;
|
|
385
|
+
}
|
|
386
|
+
switch (method) {
|
|
387
|
+
case "POST":
|
|
388
|
+
return "Created";
|
|
389
|
+
case "PUT":
|
|
390
|
+
case "PATCH":
|
|
391
|
+
return "Updated";
|
|
392
|
+
case "DELETE":
|
|
393
|
+
return "Deleted";
|
|
394
|
+
default:
|
|
395
|
+
return "Called";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function getEndpointName(path) {
|
|
399
|
+
const parts = path.replace(/^\/api\//, "").split("/");
|
|
400
|
+
if (parts.length <= 2) return parts.join("/");
|
|
401
|
+
return parts.map((p) => p.length > ENDPOINT_TRUNCATE_LENGTH ? "..." : p).join("/");
|
|
402
|
+
}
|
|
403
|
+
function prettifyPageName(path) {
|
|
404
|
+
const clean = path.replace(/^\//, "").replace(/\/$/, "");
|
|
405
|
+
if (!clean) return "Home";
|
|
406
|
+
return clean.split("/").map((s) => capitalize(s.replace(/[-_]/g, " "))).join(" ");
|
|
407
|
+
}
|
|
408
|
+
function capitalize(s) {
|
|
409
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/analysis/transforms.ts
|
|
413
|
+
function markDuplicates(requests) {
|
|
414
|
+
const counts = /* @__PURE__ */ new Map();
|
|
415
|
+
for (const req of requests) {
|
|
416
|
+
if (req.category !== "data-fetch" && req.category !== "auth-check")
|
|
417
|
+
continue;
|
|
418
|
+
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
419
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
420
|
+
}
|
|
421
|
+
const isStrictMode = counts.size > 0 && [...counts.values()].every((c) => c === 2);
|
|
422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
423
|
+
for (const req of requests) {
|
|
424
|
+
if (req.category !== "data-fetch" && req.category !== "auth-check")
|
|
425
|
+
continue;
|
|
426
|
+
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
427
|
+
if (seen.has(key)) {
|
|
428
|
+
if (isStrictMode) {
|
|
429
|
+
req.isStrictModeDupe = true;
|
|
430
|
+
} else {
|
|
431
|
+
req.isDuplicate = true;
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
seen.add(key);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function collapsePolling(requests) {
|
|
439
|
+
const result = [];
|
|
440
|
+
let i = 0;
|
|
441
|
+
while (i < requests.length) {
|
|
442
|
+
const current = requests[i];
|
|
443
|
+
const currentEffective = getEffectivePath(current).split("?")[0];
|
|
444
|
+
if (current.method === "GET" && current.category === "data-fetch") {
|
|
445
|
+
let j = i + 1;
|
|
446
|
+
while (j < requests.length && requests[j].method === "GET" && getEffectivePath(requests[j]).split("?")[0] === currentEffective) {
|
|
447
|
+
j++;
|
|
448
|
+
}
|
|
449
|
+
const count = j - i;
|
|
450
|
+
if (count >= MIN_POLLING_SEQUENCE) {
|
|
451
|
+
const last = requests[j - 1];
|
|
452
|
+
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
453
|
+
const endpointName = prettifyEndpoint(currentEffective);
|
|
454
|
+
result.push({
|
|
455
|
+
...current,
|
|
456
|
+
category: "polling",
|
|
457
|
+
label: `Polling ${endpointName} (${count}x, ${formatDurationLabel(pollingDuration)})`,
|
|
458
|
+
pollingCount: count,
|
|
459
|
+
pollingDurationMs: pollingDuration,
|
|
460
|
+
isDuplicate: false
|
|
461
|
+
});
|
|
462
|
+
i = j;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
result.push(current);
|
|
467
|
+
i++;
|
|
468
|
+
}
|
|
469
|
+
return result;
|
|
470
|
+
}
|
|
471
|
+
function formatDurationLabel(ms) {
|
|
472
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
473
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
474
|
+
}
|
|
475
|
+
function detectWarnings(requests) {
|
|
476
|
+
const warnings = [];
|
|
477
|
+
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
478
|
+
if (duplicateCount > 0) {
|
|
479
|
+
const unique = new Set(
|
|
480
|
+
requests.filter((r) => r.isDuplicate).map((r) => `${r.method} ${getEffectivePath(r).split("?")[0]}`)
|
|
481
|
+
);
|
|
482
|
+
const endpoints = unique.size;
|
|
483
|
+
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
484
|
+
const key = `${r.method} ${getEffectivePath(r).split("?")[0]}`;
|
|
485
|
+
const first = requests.find(
|
|
486
|
+
(o) => !o.isDuplicate && `${o.method} ${getEffectivePath(o).split("?")[0]}` === key
|
|
487
|
+
);
|
|
488
|
+
return first && first.responseBody === r.responseBody;
|
|
489
|
+
});
|
|
490
|
+
const suffix = sameData ? " \u2014 same data loaded twice" : "";
|
|
491
|
+
warnings.push(
|
|
492
|
+
`${duplicateCount} request${duplicateCount > 1 ? "s" : ""} duplicated across ${endpoints} endpoint${endpoints > 1 ? "s" : ""}${suffix}`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const slowRequests = requests.filter(
|
|
496
|
+
(r) => r.durationMs > SLOW_REQUEST_THRESHOLD_MS && r.category !== "polling"
|
|
497
|
+
);
|
|
498
|
+
for (const req of slowRequests) {
|
|
499
|
+
warnings.push(`${req.label} took ${(req.durationMs / 1e3).toFixed(1)}s`);
|
|
500
|
+
}
|
|
501
|
+
const errors = requests.filter((r) => r.statusCode >= 500);
|
|
502
|
+
for (const req of errors) {
|
|
503
|
+
warnings.push(`${req.label} \u2014 server error (${req.statusCode})`);
|
|
504
|
+
}
|
|
505
|
+
return warnings;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/analysis/group.ts
|
|
509
|
+
function groupRequestsIntoFlows(requests) {
|
|
510
|
+
if (requests.length === 0) return [];
|
|
511
|
+
const flows = [];
|
|
512
|
+
let currentRequests = [];
|
|
513
|
+
let currentSourcePage;
|
|
514
|
+
let lastEndTime = 0;
|
|
515
|
+
for (const req of requests) {
|
|
516
|
+
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
517
|
+
const labeled = labelRequest(req);
|
|
518
|
+
if (labeled.category === "static") continue;
|
|
519
|
+
const sourcePage = labeled.sourcePage;
|
|
520
|
+
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
521
|
+
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
522
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
523
|
+
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
524
|
+
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
525
|
+
flows.push(buildFlow(currentRequests));
|
|
526
|
+
currentRequests = [];
|
|
527
|
+
}
|
|
528
|
+
currentRequests.push(labeled);
|
|
529
|
+
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
530
|
+
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
531
|
+
}
|
|
532
|
+
if (currentRequests.length > 0) {
|
|
533
|
+
flows.push(buildFlow(currentRequests));
|
|
534
|
+
}
|
|
535
|
+
return flows;
|
|
536
|
+
}
|
|
537
|
+
function buildFlow(rawRequests) {
|
|
538
|
+
markDuplicates(rawRequests);
|
|
539
|
+
const requests = collapsePolling(rawRequests);
|
|
540
|
+
const first = requests[0];
|
|
541
|
+
const startTime = first.startedAt;
|
|
542
|
+
const endTime = Math.max(
|
|
543
|
+
...requests.map(
|
|
544
|
+
(r) => r.pollingDurationMs ? r.startedAt + r.pollingDurationMs : r.startedAt + r.durationMs
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
const duplicateCount = rawRequests.filter((r) => r.isDuplicate).length;
|
|
548
|
+
const nonStaticCount = rawRequests.length;
|
|
549
|
+
const redundancyPct = nonStaticCount > 0 ? Math.round(duplicateCount / nonStaticCount * 100) : 0;
|
|
550
|
+
const sourcePage = getDominantSourcePage(rawRequests);
|
|
551
|
+
return {
|
|
552
|
+
id: randomUUID2(),
|
|
553
|
+
label: deriveFlowLabel(requests, sourcePage),
|
|
554
|
+
requests,
|
|
555
|
+
startTime,
|
|
556
|
+
totalDurationMs: Math.round(endTime - startTime),
|
|
557
|
+
hasErrors: requests.some((r) => r.statusCode >= 400),
|
|
558
|
+
warnings: detectWarnings(rawRequests),
|
|
559
|
+
sourcePage,
|
|
560
|
+
redundancyPct
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function getDominantSourcePage(requests) {
|
|
564
|
+
const counts = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const req of requests) {
|
|
566
|
+
if (req.sourcePage) {
|
|
567
|
+
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
let best = "";
|
|
571
|
+
let bestCount = 0;
|
|
572
|
+
for (const [page, count] of counts) {
|
|
573
|
+
if (count > bestCount) {
|
|
574
|
+
best = page;
|
|
575
|
+
bestCount = count;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return best || requests[0]?.path?.split("?")[0] || "/";
|
|
579
|
+
}
|
|
580
|
+
function deriveFlowLabel(requests, sourcePage) {
|
|
581
|
+
const trigger = requests.find((r) => r.category === "api-call") ?? requests.find((r) => r.category === "server-action") ?? requests.find((r) => r.category === "page-load") ?? requests.find((r) => r.category === "navigation") ?? requests.find((r) => r.category === "data-fetch") ?? requests[0];
|
|
582
|
+
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
583
|
+
const pageName = prettifyPageName(trigger.path.split("?")[0]);
|
|
584
|
+
return `${pageName} Page`;
|
|
585
|
+
}
|
|
586
|
+
if (trigger.category === "api-call") {
|
|
587
|
+
const effectivePath = getEffectivePath(trigger);
|
|
588
|
+
const parts = effectivePath.replace(/^\/api\//, "").split("/");
|
|
589
|
+
const endpointName = parts.length <= 2 ? parts.join("/") : parts.map((p) => p.length > 12 ? "..." : p).join("/");
|
|
590
|
+
const action = deriveActionVerb(trigger.method, endpointName);
|
|
591
|
+
const name = prettifyEndpoint(endpointName);
|
|
592
|
+
return `${action} ${capitalize(name)}`;
|
|
593
|
+
}
|
|
594
|
+
if (trigger.category === "server-action") {
|
|
595
|
+
const name = prettifyEndpoint(trigger.path);
|
|
596
|
+
return capitalize(name);
|
|
597
|
+
}
|
|
598
|
+
if (trigger.category === "data-fetch" || trigger.category === "polling") {
|
|
599
|
+
if (sourcePage && sourcePage !== "/") {
|
|
600
|
+
const pageName = prettifyPageName(sourcePage);
|
|
601
|
+
return `${pageName} Page`;
|
|
602
|
+
}
|
|
603
|
+
return trigger.label;
|
|
604
|
+
}
|
|
605
|
+
return trigger.label;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/store/telemetry-store.ts
|
|
609
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
610
|
+
var TelemetryStore = class {
|
|
611
|
+
constructor(maxEntries = MAX_TELEMETRY_ENTRIES) {
|
|
612
|
+
this.maxEntries = maxEntries;
|
|
613
|
+
}
|
|
614
|
+
entries = [];
|
|
615
|
+
listeners = [];
|
|
616
|
+
add(data) {
|
|
617
|
+
const entry = { id: randomUUID3(), ...data };
|
|
618
|
+
this.entries.push(entry);
|
|
619
|
+
if (this.entries.length > this.maxEntries) this.entries.shift();
|
|
620
|
+
for (const fn of this.listeners) fn(entry);
|
|
621
|
+
return entry;
|
|
622
|
+
}
|
|
623
|
+
getAll() {
|
|
624
|
+
return this.entries;
|
|
625
|
+
}
|
|
626
|
+
getByRequest(requestId) {
|
|
627
|
+
return this.entries.filter((e) => e.parentRequestId === requestId);
|
|
628
|
+
}
|
|
629
|
+
clear() {
|
|
630
|
+
this.entries.length = 0;
|
|
631
|
+
}
|
|
632
|
+
onEntry(fn) {
|
|
633
|
+
this.listeners.push(fn);
|
|
634
|
+
}
|
|
635
|
+
offEntry(fn) {
|
|
636
|
+
const idx = this.listeners.indexOf(fn);
|
|
637
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// src/store/fetch-store.ts
|
|
642
|
+
var FetchStore = class extends TelemetryStore {
|
|
643
|
+
};
|
|
644
|
+
var defaultFetchStore = new FetchStore();
|
|
645
|
+
|
|
646
|
+
// src/store/log-store.ts
|
|
647
|
+
var LogStore = class extends TelemetryStore {
|
|
648
|
+
};
|
|
649
|
+
var defaultLogStore = new LogStore();
|
|
650
|
+
|
|
651
|
+
// src/store/error-store.ts
|
|
652
|
+
var ErrorStore = class extends TelemetryStore {
|
|
653
|
+
};
|
|
654
|
+
var defaultErrorStore = new ErrorStore();
|
|
655
|
+
|
|
656
|
+
// src/store/query-store.ts
|
|
657
|
+
var QueryStore = class extends TelemetryStore {
|
|
658
|
+
};
|
|
659
|
+
var defaultQueryStore = new QueryStore();
|
|
660
|
+
|
|
661
|
+
// src/store/metrics/metrics-store.ts
|
|
662
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
663
|
+
|
|
664
|
+
// src/store/metrics/persistence.ts
|
|
665
|
+
import {
|
|
666
|
+
readFileSync as readFileSync2,
|
|
667
|
+
writeFileSync as writeFileSync2,
|
|
668
|
+
mkdirSync as mkdirSync2,
|
|
669
|
+
existsSync as existsSync2,
|
|
670
|
+
unlinkSync
|
|
671
|
+
} from "fs";
|
|
672
|
+
import { resolve as resolve2 } from "path";
|
|
673
|
+
|
|
674
|
+
// src/utils/fs.ts
|
|
675
|
+
import { access } from "fs/promises";
|
|
676
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
677
|
+
import { resolve } from "path";
|
|
678
|
+
async function fileExists(path) {
|
|
679
|
+
try {
|
|
680
|
+
await access(path);
|
|
681
|
+
return true;
|
|
682
|
+
} catch {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/dashboard/client/constants/thresholds.ts
|
|
688
|
+
var HEALTH_FAST_MS = 100;
|
|
689
|
+
var HEALTH_GOOD_MS = 300;
|
|
690
|
+
var HEALTH_OK_MS = 800;
|
|
691
|
+
var HEALTH_SLOW_MS = 2e3;
|
|
692
|
+
|
|
693
|
+
// src/dashboard/client/constants/display.ts
|
|
694
|
+
var HEALTH_GRADES = `[
|
|
695
|
+
{ max: ${HEALTH_FAST_MS}, label: 'Fast', color: 'var(--green)', bg: 'rgba(22,163,74,0.08)', border: 'rgba(22,163,74,0.2)' },
|
|
696
|
+
{ max: ${HEALTH_GOOD_MS}, label: 'Good', color: 'var(--green)', bg: 'rgba(22,163,74,0.06)', border: 'rgba(22,163,74,0.15)' },
|
|
697
|
+
{ max: ${HEALTH_OK_MS}, label: 'OK', color: 'var(--amber)', bg: 'rgba(217,119,6,0.06)', border: 'rgba(217,119,6,0.15)' },
|
|
698
|
+
{ max: ${HEALTH_SLOW_MS}, label: 'Slow', color: 'var(--red)', bg: 'rgba(220,38,38,0.06)', border: 'rgba(220,38,38,0.15)' },
|
|
699
|
+
{ max: Infinity, label: 'Critical', color: 'var(--red)', bg: 'rgba(220,38,38,0.08)', border: 'rgba(220,38,38,0.2)' }
|
|
700
|
+
]`;
|
|
701
|
+
|
|
702
|
+
// src/dashboard/router.ts
|
|
703
|
+
function isDashboardRequest(url) {
|
|
704
|
+
return url === DASHBOARD_PREFIX || url.startsWith(DASHBOARD_PREFIX + "/");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// src/proxy/server.ts
|
|
708
|
+
function createProxyServer(config, handleDashboard) {
|
|
709
|
+
const server = createServer((clientReq, clientRes) => {
|
|
710
|
+
if (isDashboardRequest(clientReq.url ?? "")) {
|
|
711
|
+
handleDashboard(clientReq, clientRes, config);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
proxyRequest(clientReq, clientRes, config);
|
|
715
|
+
});
|
|
716
|
+
server.on("upgrade", (req, socket, head) => {
|
|
717
|
+
handleUpgrade(req, socket, head, config.targetPort);
|
|
718
|
+
});
|
|
719
|
+
return server;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/detect/project.ts
|
|
723
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
724
|
+
import { join } from "path";
|
|
725
|
+
var FRAMEWORKS = [
|
|
726
|
+
{ name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
727
|
+
{ name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
|
|
728
|
+
{ name: "nuxt", dep: "nuxt", devCmd: "nuxt dev", bin: "nuxt", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
729
|
+
{ name: "vite", dep: "vite", devCmd: "vite", bin: "vite", defaultPort: 5173, devArgs: ["--port"] },
|
|
730
|
+
{ name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
|
|
731
|
+
];
|
|
732
|
+
async function detectProject(rootDir) {
|
|
733
|
+
const pkgPath = join(rootDir, "package.json");
|
|
734
|
+
const raw = await readFile2(pkgPath, "utf-8");
|
|
735
|
+
const pkg = JSON.parse(raw);
|
|
736
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
737
|
+
let framework = "unknown";
|
|
738
|
+
let devCommand = "";
|
|
739
|
+
let devBin = "";
|
|
740
|
+
let defaultPort = 3e3;
|
|
741
|
+
for (const f of FRAMEWORKS) {
|
|
742
|
+
if (allDeps[f.dep]) {
|
|
743
|
+
framework = f.name;
|
|
744
|
+
devCommand = f.devCmd;
|
|
745
|
+
devBin = join(rootDir, "node_modules", ".bin", f.bin);
|
|
746
|
+
defaultPort = f.defaultPort;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const packageManager = await detectPackageManager(rootDir);
|
|
751
|
+
return { framework, devCommand, devBin, defaultPort, packageManager };
|
|
752
|
+
}
|
|
753
|
+
async function detectPackageManager(rootDir) {
|
|
754
|
+
if (await fileExists(join(rootDir, "bun.lockb"))) return "bun";
|
|
755
|
+
if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
|
|
756
|
+
if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
757
|
+
if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
|
|
758
|
+
if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
|
|
759
|
+
return "unknown";
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/instrument/adapter-registry.ts
|
|
763
|
+
var AdapterRegistry = class {
|
|
764
|
+
adapters = [];
|
|
765
|
+
active = [];
|
|
766
|
+
register(adapter) {
|
|
767
|
+
this.adapters.push(adapter);
|
|
768
|
+
}
|
|
769
|
+
patchAll(emit) {
|
|
770
|
+
for (const adapter of this.adapters) {
|
|
771
|
+
try {
|
|
772
|
+
if (adapter.detect()) {
|
|
773
|
+
adapter.patch(emit);
|
|
774
|
+
this.active.push(adapter);
|
|
775
|
+
}
|
|
776
|
+
} catch {
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
unpatchAll() {
|
|
781
|
+
for (const adapter of this.active) {
|
|
782
|
+
try {
|
|
783
|
+
adapter.unpatch?.();
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
this.active = [];
|
|
788
|
+
}
|
|
789
|
+
getActive() {
|
|
790
|
+
return this.active;
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// src/analysis/rules/patterns.ts
|
|
795
|
+
var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
|
|
796
|
+
var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
|
|
797
|
+
var SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
|
|
798
|
+
var STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections/;
|
|
799
|
+
var DB_CONN_RE = /(postgres|mysql|mongodb|redis):\/\//;
|
|
800
|
+
var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i;
|
|
801
|
+
var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
|
|
802
|
+
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
|
|
803
|
+
var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
804
|
+
var RULE_HINTS = {
|
|
805
|
+
"exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
|
|
806
|
+
"token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
|
|
807
|
+
"stack-trace-leak": "Use a custom error handler that returns generic messages in production.",
|
|
808
|
+
"error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
|
|
809
|
+
"sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
|
|
810
|
+
"cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
|
|
811
|
+
"insecure-cookie": "Set HttpOnly and SameSite flags on all cookies."
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// src/analysis/rules/exposed-secret.ts
|
|
815
|
+
function tryParseJson(body) {
|
|
816
|
+
if (!body) return null;
|
|
817
|
+
try {
|
|
818
|
+
return JSON.parse(body);
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function findSecretKeys(obj, prefix) {
|
|
824
|
+
const found = [];
|
|
825
|
+
if (!obj || typeof obj !== "object") return found;
|
|
826
|
+
if (Array.isArray(obj)) {
|
|
827
|
+
for (let i = 0; i < Math.min(obj.length, 5); i++) {
|
|
828
|
+
found.push(...findSecretKeys(obj[i], prefix));
|
|
829
|
+
}
|
|
830
|
+
return found;
|
|
831
|
+
}
|
|
832
|
+
for (const k of Object.keys(obj)) {
|
|
833
|
+
const val = obj[k];
|
|
834
|
+
if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= 8 && !MASKED_RE.test(val)) {
|
|
835
|
+
found.push(k);
|
|
836
|
+
}
|
|
837
|
+
if (typeof val === "object" && val !== null) {
|
|
838
|
+
found.push(...findSecretKeys(val, prefix + k + "."));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return found;
|
|
842
|
+
}
|
|
843
|
+
var exposedSecretRule = {
|
|
844
|
+
id: "exposed-secret",
|
|
845
|
+
severity: "critical",
|
|
846
|
+
name: "Exposed Secret in Response",
|
|
847
|
+
hint: RULE_HINTS["exposed-secret"],
|
|
848
|
+
check(ctx) {
|
|
849
|
+
const findings = [];
|
|
850
|
+
const seen = /* @__PURE__ */ new Map();
|
|
851
|
+
for (const r of ctx.requests) {
|
|
852
|
+
if (r.statusCode >= 400) continue;
|
|
853
|
+
const parsed = tryParseJson(r.responseBody);
|
|
854
|
+
if (!parsed) continue;
|
|
855
|
+
const keys = findSecretKeys(parsed, "");
|
|
856
|
+
if (keys.length === 0) continue;
|
|
857
|
+
const ep = `${r.method} ${r.path}`;
|
|
858
|
+
const dedupKey = `${ep}:${keys.sort().join(",")}`;
|
|
859
|
+
const existing = seen.get(dedupKey);
|
|
860
|
+
if (existing) {
|
|
861
|
+
existing.count++;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const finding = {
|
|
865
|
+
severity: "critical",
|
|
866
|
+
rule: "exposed-secret",
|
|
867
|
+
title: "Exposed Secret in Response",
|
|
868
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
869
|
+
hint: this.hint,
|
|
870
|
+
endpoint: ep,
|
|
871
|
+
count: 1
|
|
872
|
+
};
|
|
873
|
+
seen.set(dedupKey, finding);
|
|
874
|
+
findings.push(finding);
|
|
875
|
+
}
|
|
876
|
+
return findings;
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// src/analysis/rules/token-in-url.ts
|
|
881
|
+
var tokenInUrlRule = {
|
|
882
|
+
id: "token-in-url",
|
|
883
|
+
severity: "critical",
|
|
884
|
+
name: "Auth Token in URL",
|
|
885
|
+
hint: RULE_HINTS["token-in-url"],
|
|
886
|
+
check(ctx) {
|
|
887
|
+
const findings = [];
|
|
888
|
+
const seen = /* @__PURE__ */ new Map();
|
|
889
|
+
for (const r of ctx.requests) {
|
|
890
|
+
const qIdx = r.url.indexOf("?");
|
|
891
|
+
if (qIdx === -1) continue;
|
|
892
|
+
const params = r.url.substring(qIdx + 1).split("&");
|
|
893
|
+
const flagged = [];
|
|
894
|
+
for (const param of params) {
|
|
895
|
+
const [name, ...rest] = param.split("=");
|
|
896
|
+
const val = rest.join("=");
|
|
897
|
+
if (SAFE_PARAMS.test(name)) continue;
|
|
898
|
+
if (TOKEN_PARAMS.test(name) && val && val.length > 0) {
|
|
899
|
+
flagged.push(name);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (flagged.length === 0) continue;
|
|
903
|
+
const ep = `${r.method} ${r.path}`;
|
|
904
|
+
const dedupKey = `${ep}:${flagged.sort().join(",")}`;
|
|
905
|
+
const existing = seen.get(dedupKey);
|
|
906
|
+
if (existing) {
|
|
907
|
+
existing.count++;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const finding = {
|
|
911
|
+
severity: "critical",
|
|
912
|
+
rule: "token-in-url",
|
|
913
|
+
title: "Auth Token in URL",
|
|
914
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
915
|
+
hint: this.hint,
|
|
916
|
+
endpoint: ep,
|
|
917
|
+
count: 1
|
|
918
|
+
};
|
|
919
|
+
seen.set(dedupKey, finding);
|
|
920
|
+
findings.push(finding);
|
|
921
|
+
}
|
|
922
|
+
return findings;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// src/analysis/rules/stack-trace-leak.ts
|
|
927
|
+
var stackTraceLeakRule = {
|
|
928
|
+
id: "stack-trace-leak",
|
|
929
|
+
severity: "critical",
|
|
930
|
+
name: "Stack Trace Leaked to Client",
|
|
931
|
+
hint: RULE_HINTS["stack-trace-leak"],
|
|
932
|
+
check(ctx) {
|
|
933
|
+
const findings = [];
|
|
934
|
+
const seen = /* @__PURE__ */ new Map();
|
|
935
|
+
for (const r of ctx.requests) {
|
|
936
|
+
if (!r.responseBody) continue;
|
|
937
|
+
if (!STACK_TRACE_RE.test(r.responseBody)) continue;
|
|
938
|
+
const ep = `${r.method} ${r.path}`;
|
|
939
|
+
const existing = seen.get(ep);
|
|
940
|
+
if (existing) {
|
|
941
|
+
existing.count++;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const finding = {
|
|
945
|
+
severity: "critical",
|
|
946
|
+
rule: "stack-trace-leak",
|
|
947
|
+
title: "Stack Trace Leaked to Client",
|
|
948
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
949
|
+
hint: this.hint,
|
|
950
|
+
endpoint: ep,
|
|
951
|
+
count: 1
|
|
952
|
+
};
|
|
953
|
+
seen.set(ep, finding);
|
|
954
|
+
findings.push(finding);
|
|
955
|
+
}
|
|
956
|
+
return findings;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// src/analysis/rules/error-info-leak.ts
|
|
961
|
+
var CRITICAL_PATTERNS = [
|
|
962
|
+
{ re: DB_CONN_RE, label: "database connection string" },
|
|
963
|
+
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
964
|
+
{ re: SECRET_VAL_RE, label: "secret value" }
|
|
965
|
+
];
|
|
966
|
+
var errorInfoLeakRule = {
|
|
967
|
+
id: "error-info-leak",
|
|
968
|
+
severity: "critical",
|
|
969
|
+
name: "Sensitive Data in Error Response",
|
|
970
|
+
hint: RULE_HINTS["error-info-leak"],
|
|
971
|
+
check(ctx) {
|
|
972
|
+
const findings = [];
|
|
973
|
+
const seen = /* @__PURE__ */ new Map();
|
|
974
|
+
for (const r of ctx.requests) {
|
|
975
|
+
if (r.statusCode < 400) continue;
|
|
976
|
+
if (!r.responseBody) continue;
|
|
977
|
+
if (r.responseHeaders["x-nextjs-error"] || r.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
978
|
+
const ep = `${r.method} ${r.path}`;
|
|
979
|
+
for (const p of CRITICAL_PATTERNS) {
|
|
980
|
+
if (!p.re.test(r.responseBody)) continue;
|
|
981
|
+
const dedupKey = `${ep}:${p.label}`;
|
|
982
|
+
const existing = seen.get(dedupKey);
|
|
983
|
+
if (existing) {
|
|
984
|
+
existing.count++;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const finding = {
|
|
988
|
+
severity: "critical",
|
|
989
|
+
rule: "error-info-leak",
|
|
990
|
+
title: "Sensitive Data in Error Response",
|
|
991
|
+
desc: `${ep} \u2014 error response exposes ${p.label}`,
|
|
992
|
+
hint: this.hint,
|
|
993
|
+
endpoint: ep,
|
|
994
|
+
count: 1
|
|
995
|
+
};
|
|
996
|
+
seen.set(dedupKey, finding);
|
|
997
|
+
findings.push(finding);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return findings;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// src/analysis/rules/insecure-cookie.ts
|
|
1005
|
+
var insecureCookieRule = {
|
|
1006
|
+
id: "insecure-cookie",
|
|
1007
|
+
severity: "warning",
|
|
1008
|
+
name: "Insecure Cookie",
|
|
1009
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
1010
|
+
check(ctx) {
|
|
1011
|
+
const findings = [];
|
|
1012
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1013
|
+
for (const r of ctx.requests) {
|
|
1014
|
+
if (!r.responseHeaders) continue;
|
|
1015
|
+
const setCookie = r.responseHeaders["set-cookie"];
|
|
1016
|
+
if (!setCookie) continue;
|
|
1017
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
1018
|
+
for (const cookie of cookies) {
|
|
1019
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
1020
|
+
const lower = cookie.toLowerCase();
|
|
1021
|
+
const issues = [];
|
|
1022
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
1023
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
1024
|
+
if (issues.length === 0) continue;
|
|
1025
|
+
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
1026
|
+
const existing = seen.get(dedupKey);
|
|
1027
|
+
if (existing) {
|
|
1028
|
+
existing.count++;
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const finding = {
|
|
1032
|
+
severity: "warning",
|
|
1033
|
+
rule: "insecure-cookie",
|
|
1034
|
+
title: "Insecure Cookie",
|
|
1035
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
1036
|
+
hint: this.hint,
|
|
1037
|
+
endpoint: cookieName,
|
|
1038
|
+
count: 1
|
|
1039
|
+
};
|
|
1040
|
+
seen.set(dedupKey, finding);
|
|
1041
|
+
findings.push(finding);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return findings;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// src/analysis/rules/sensitive-logs.ts
|
|
1049
|
+
var sensitiveLogsRule = {
|
|
1050
|
+
id: "sensitive-logs",
|
|
1051
|
+
severity: "warning",
|
|
1052
|
+
name: "Sensitive Data in Logs",
|
|
1053
|
+
hint: RULE_HINTS["sensitive-logs"],
|
|
1054
|
+
check(ctx) {
|
|
1055
|
+
let count = 0;
|
|
1056
|
+
for (const log of ctx.logs) {
|
|
1057
|
+
if (!log.message) continue;
|
|
1058
|
+
if (log.message.startsWith("[brakit]")) continue;
|
|
1059
|
+
if (LOG_SECRET_RE.test(log.message)) count++;
|
|
1060
|
+
}
|
|
1061
|
+
if (count === 0) return [];
|
|
1062
|
+
return [{
|
|
1063
|
+
severity: "warning",
|
|
1064
|
+
rule: "sensitive-logs",
|
|
1065
|
+
title: "Sensitive Data in Logs",
|
|
1066
|
+
desc: `Console output contains secret/token values \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1067
|
+
hint: this.hint,
|
|
1068
|
+
endpoint: "console",
|
|
1069
|
+
count
|
|
1070
|
+
}];
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
// src/analysis/rules/cors-credentials.ts
|
|
1075
|
+
var corsCredentialsRule = {
|
|
1076
|
+
id: "cors-credentials",
|
|
1077
|
+
severity: "warning",
|
|
1078
|
+
name: "CORS Credentials with Wildcard",
|
|
1079
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
1080
|
+
check(ctx) {
|
|
1081
|
+
const findings = [];
|
|
1082
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1083
|
+
for (const r of ctx.requests) {
|
|
1084
|
+
if (!r.responseHeaders) continue;
|
|
1085
|
+
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
1086
|
+
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
1087
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
1088
|
+
const ep = `${r.method} ${r.path}`;
|
|
1089
|
+
if (seen.has(ep)) continue;
|
|
1090
|
+
seen.add(ep);
|
|
1091
|
+
findings.push({
|
|
1092
|
+
severity: "warning",
|
|
1093
|
+
rule: "cors-credentials",
|
|
1094
|
+
title: "CORS Credentials with Wildcard",
|
|
1095
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
1096
|
+
hint: this.hint,
|
|
1097
|
+
endpoint: ep,
|
|
1098
|
+
count: 1
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
return findings;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
// src/analysis/rules/scanner.ts
|
|
1106
|
+
var SecurityScanner = class {
|
|
1107
|
+
rules = [];
|
|
1108
|
+
register(rule) {
|
|
1109
|
+
this.rules.push(rule);
|
|
1110
|
+
}
|
|
1111
|
+
scan(ctx) {
|
|
1112
|
+
const findings = [];
|
|
1113
|
+
for (const rule of this.rules) {
|
|
1114
|
+
try {
|
|
1115
|
+
findings.push(...rule.check(ctx));
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return findings;
|
|
1120
|
+
}
|
|
1121
|
+
getRules() {
|
|
1122
|
+
return this.rules;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
function createDefaultScanner() {
|
|
1126
|
+
const scanner = new SecurityScanner();
|
|
1127
|
+
scanner.register(exposedSecretRule);
|
|
1128
|
+
scanner.register(tokenInUrlRule);
|
|
1129
|
+
scanner.register(stackTraceLeakRule);
|
|
1130
|
+
scanner.register(errorInfoLeakRule);
|
|
1131
|
+
scanner.register(insecureCookieRule);
|
|
1132
|
+
scanner.register(sensitiveLogsRule);
|
|
1133
|
+
scanner.register(corsCredentialsRule);
|
|
1134
|
+
return scanner;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/instrument/adapters/normalize.ts
|
|
1138
|
+
function normalizeSQL(sql) {
|
|
1139
|
+
if (!sql) return { op: "OTHER", table: "" };
|
|
1140
|
+
const trimmed = sql.trim();
|
|
1141
|
+
const op = trimmed.split(/\s+/)[0].toUpperCase();
|
|
1142
|
+
if (/SELECT\s+COUNT/i.test(trimmed)) {
|
|
1143
|
+
const match = trimmed.match(/FROM\s+"?\w+"?\."?(\w+)"?/i);
|
|
1144
|
+
return { op: "SELECT", table: match?.[1] ?? "" };
|
|
1145
|
+
}
|
|
1146
|
+
const tableMatch = trimmed.match(/(?:FROM|INTO|UPDATE)\s+"?\w+"?\."?(\w+)"?/i);
|
|
1147
|
+
const table = tableMatch?.[1] ?? "";
|
|
1148
|
+
switch (op) {
|
|
1149
|
+
case "SELECT":
|
|
1150
|
+
return { op: "SELECT", table };
|
|
1151
|
+
case "INSERT":
|
|
1152
|
+
return { op: "INSERT", table };
|
|
1153
|
+
case "UPDATE":
|
|
1154
|
+
return { op: "UPDATE", table };
|
|
1155
|
+
case "DELETE":
|
|
1156
|
+
return { op: "DELETE", table };
|
|
1157
|
+
default:
|
|
1158
|
+
return { op: "OTHER", table };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function normalizeQueryParams(sql) {
|
|
1162
|
+
if (!sql) return null;
|
|
1163
|
+
let n = sql.replace(/'[^']*'/g, "?");
|
|
1164
|
+
n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
|
|
1165
|
+
n = n.replace(/\$\d+/g, "?");
|
|
1166
|
+
return n;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/analysis/insights.ts
|
|
1170
|
+
var AUTH_CATEGORIES = /* @__PURE__ */ new Set(["auth-handshake", "auth-check", "middleware"]);
|
|
1171
|
+
function getQueryShape(q) {
|
|
1172
|
+
if (q.sql) return normalizeQueryParams(q.sql) ?? "";
|
|
1173
|
+
return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
|
|
1174
|
+
}
|
|
1175
|
+
function getQueryInfo(q) {
|
|
1176
|
+
if (q.sql) return normalizeSQL(q.sql);
|
|
1177
|
+
return {
|
|
1178
|
+
op: q.normalizedOp ?? q.operation ?? "?",
|
|
1179
|
+
table: q.table ?? q.model ?? ""
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
function formatDuration(ms) {
|
|
1183
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1184
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1185
|
+
}
|
|
1186
|
+
function formatSize(bytes) {
|
|
1187
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1188
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1189
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1190
|
+
}
|
|
1191
|
+
function computeInsights(ctx) {
|
|
1192
|
+
const insights = [];
|
|
1193
|
+
const nonStatic = ctx.requests.filter(
|
|
1194
|
+
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1195
|
+
);
|
|
1196
|
+
const queriesByReq = /* @__PURE__ */ new Map();
|
|
1197
|
+
for (const q of ctx.queries) {
|
|
1198
|
+
if (!q.parentRequestId) continue;
|
|
1199
|
+
let arr = queriesByReq.get(q.parentRequestId);
|
|
1200
|
+
if (!arr) {
|
|
1201
|
+
arr = [];
|
|
1202
|
+
queriesByReq.set(q.parentRequestId, arr);
|
|
1203
|
+
}
|
|
1204
|
+
arr.push(q);
|
|
1205
|
+
}
|
|
1206
|
+
const reqById = /* @__PURE__ */ new Map();
|
|
1207
|
+
for (const r of nonStatic) reqById.set(r.id, r);
|
|
1208
|
+
const n1Seen = /* @__PURE__ */ new Set();
|
|
1209
|
+
for (const [reqId, reqQueries] of queriesByReq) {
|
|
1210
|
+
const req = reqById.get(reqId);
|
|
1211
|
+
if (!req) continue;
|
|
1212
|
+
const endpoint = `${req.method} ${req.path}`;
|
|
1213
|
+
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1214
|
+
for (const q of reqQueries) {
|
|
1215
|
+
const shape = getQueryShape(q);
|
|
1216
|
+
let group = shapeGroups.get(shape);
|
|
1217
|
+
if (!group) {
|
|
1218
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: q };
|
|
1219
|
+
shapeGroups.set(shape, group);
|
|
1220
|
+
}
|
|
1221
|
+
group.count++;
|
|
1222
|
+
group.distinctSql.add(q.sql ?? shape);
|
|
1223
|
+
}
|
|
1224
|
+
for (const [, sg] of shapeGroups) {
|
|
1225
|
+
if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
|
|
1226
|
+
const info = getQueryInfo(sg.first);
|
|
1227
|
+
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
|
|
1228
|
+
if (n1Seen.has(key)) continue;
|
|
1229
|
+
n1Seen.add(key);
|
|
1230
|
+
insights.push({
|
|
1231
|
+
severity: "critical",
|
|
1232
|
+
type: "n1",
|
|
1233
|
+
title: "N+1 Query Pattern",
|
|
1234
|
+
desc: `${endpoint} runs ${sg.count}x ${info.op} ${info.table} with different params in a single request`,
|
|
1235
|
+
hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
|
|
1236
|
+
nav: "queries"
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
const ceQueryMap = /* @__PURE__ */ new Map();
|
|
1241
|
+
const ceAllEndpoints = /* @__PURE__ */ new Set();
|
|
1242
|
+
for (const [reqId, reqQueries] of queriesByReq) {
|
|
1243
|
+
const req = reqById.get(reqId);
|
|
1244
|
+
if (!req) continue;
|
|
1245
|
+
const endpoint = `${req.method} ${req.path}`;
|
|
1246
|
+
ceAllEndpoints.add(endpoint);
|
|
1247
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
1248
|
+
for (const q of reqQueries) {
|
|
1249
|
+
const shape = getQueryShape(q);
|
|
1250
|
+
let entry = ceQueryMap.get(shape);
|
|
1251
|
+
if (!entry) {
|
|
1252
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1253
|
+
ceQueryMap.set(shape, entry);
|
|
1254
|
+
}
|
|
1255
|
+
entry.count++;
|
|
1256
|
+
if (!seenInReq.has(shape)) {
|
|
1257
|
+
seenInReq.add(shape);
|
|
1258
|
+
entry.endpoints.add(endpoint);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (ceAllEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1263
|
+
for (const [, cem] of ceQueryMap) {
|
|
1264
|
+
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1265
|
+
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1266
|
+
const pct = Math.round(cem.endpoints.size / ceAllEndpoints.size * 100);
|
|
1267
|
+
if (pct < CROSS_ENDPOINT_PCT) continue;
|
|
1268
|
+
const info = getQueryInfo(cem.first);
|
|
1269
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1270
|
+
insights.push({
|
|
1271
|
+
severity: "warning",
|
|
1272
|
+
type: "cross-endpoint",
|
|
1273
|
+
title: "Repeated Query Across Endpoints",
|
|
1274
|
+
desc: `${label} runs on ${cem.endpoints.size} of ${ceAllEndpoints.size} endpoints (${pct}%).`,
|
|
1275
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1276
|
+
nav: "queries"
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const rqSeen = /* @__PURE__ */ new Set();
|
|
1281
|
+
for (const [reqId, reqQueries] of queriesByReq) {
|
|
1282
|
+
const req = reqById.get(reqId);
|
|
1283
|
+
if (!req) continue;
|
|
1284
|
+
const endpoint = `${req.method} ${req.path}`;
|
|
1285
|
+
const exact = /* @__PURE__ */ new Map();
|
|
1286
|
+
for (const q of reqQueries) {
|
|
1287
|
+
if (!q.sql) continue;
|
|
1288
|
+
let entry = exact.get(q.sql);
|
|
1289
|
+
if (!entry) {
|
|
1290
|
+
entry = { count: 0, first: q };
|
|
1291
|
+
exact.set(q.sql, entry);
|
|
1292
|
+
}
|
|
1293
|
+
entry.count++;
|
|
1294
|
+
}
|
|
1295
|
+
for (const [, e] of exact) {
|
|
1296
|
+
if (e.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1297
|
+
const info = getQueryInfo(e.first);
|
|
1298
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1299
|
+
const dedupKey = `${endpoint}:${label}`;
|
|
1300
|
+
if (rqSeen.has(dedupKey)) continue;
|
|
1301
|
+
rqSeen.add(dedupKey);
|
|
1302
|
+
insights.push({
|
|
1303
|
+
severity: "warning",
|
|
1304
|
+
type: "redundant-query",
|
|
1305
|
+
title: "Redundant Query",
|
|
1306
|
+
desc: `${label} runs ${e.count}x with identical params in ${endpoint}.`,
|
|
1307
|
+
hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
|
|
1308
|
+
nav: "queries"
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
if (ctx.errors.length > 0) {
|
|
1313
|
+
const errGroups = /* @__PURE__ */ new Map();
|
|
1314
|
+
for (const e of ctx.errors) {
|
|
1315
|
+
const name = e.name || "Error";
|
|
1316
|
+
errGroups.set(name, (errGroups.get(name) ?? 0) + 1);
|
|
1317
|
+
}
|
|
1318
|
+
for (const [name, cnt] of errGroups) {
|
|
1319
|
+
insights.push({
|
|
1320
|
+
severity: "critical",
|
|
1321
|
+
type: "error",
|
|
1322
|
+
title: "Unhandled Error",
|
|
1323
|
+
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1324
|
+
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1325
|
+
nav: "errors"
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1330
|
+
for (const r of nonStatic) {
|
|
1331
|
+
const ep = `${r.method} ${r.path}`;
|
|
1332
|
+
let g = endpointGroups.get(ep);
|
|
1333
|
+
if (!g) {
|
|
1334
|
+
g = { total: 0, errors: 0, totalDuration: 0, queryCount: 0, totalSize: 0 };
|
|
1335
|
+
endpointGroups.set(ep, g);
|
|
1336
|
+
}
|
|
1337
|
+
g.total++;
|
|
1338
|
+
if (r.statusCode >= 400) g.errors++;
|
|
1339
|
+
g.totalDuration += r.durationMs;
|
|
1340
|
+
g.queryCount += (queriesByReq.get(r.id) ?? []).length;
|
|
1341
|
+
g.totalSize += r.responseSize ?? 0;
|
|
1342
|
+
}
|
|
1343
|
+
for (const [ep, g] of endpointGroups) {
|
|
1344
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1345
|
+
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1346
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1347
|
+
insights.push({
|
|
1348
|
+
severity: "critical",
|
|
1349
|
+
type: "error-hotspot",
|
|
1350
|
+
title: "Error Hotspot",
|
|
1351
|
+
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1352
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1353
|
+
nav: "requests"
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
1358
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
1359
|
+
for (const flow of ctx.flows) {
|
|
1360
|
+
if (!flow.requests) continue;
|
|
1361
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1362
|
+
for (const fr of flow.requests) {
|
|
1363
|
+
if (!fr.isDuplicate) continue;
|
|
1364
|
+
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1365
|
+
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1366
|
+
if (!seenInFlow.has(dupKey)) {
|
|
1367
|
+
seenInFlow.add(dupKey);
|
|
1368
|
+
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1373
|
+
for (let i = 0; i < Math.min(dupEntries.length, 3); i++) {
|
|
1374
|
+
const d = dupEntries[i];
|
|
1375
|
+
insights.push({
|
|
1376
|
+
severity: "warning",
|
|
1377
|
+
type: "duplicate",
|
|
1378
|
+
title: "Duplicate API Call",
|
|
1379
|
+
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1380
|
+
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
|
|
1381
|
+
nav: "actions"
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
for (const [ep, g] of endpointGroups) {
|
|
1385
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1386
|
+
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1387
|
+
if (avgMs >= SLOW_ENDPOINT_THRESHOLD_MS) {
|
|
1388
|
+
insights.push({
|
|
1389
|
+
severity: "warning",
|
|
1390
|
+
type: "slow",
|
|
1391
|
+
title: "Slow Endpoint",
|
|
1392
|
+
desc: `${ep} \u2014 avg ${formatDuration(avgMs)} across ${g.total} request${g.total !== 1 ? "s" : ""}`,
|
|
1393
|
+
hint: "Consistently slow responses hurt user experience. Check the Queries tab to see if database queries are the bottleneck.",
|
|
1394
|
+
nav: "requests"
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
for (const [ep, g] of endpointGroups) {
|
|
1399
|
+
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1400
|
+
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1401
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1402
|
+
insights.push({
|
|
1403
|
+
severity: "warning",
|
|
1404
|
+
type: "query-heavy",
|
|
1405
|
+
title: "Query-Heavy Endpoint",
|
|
1406
|
+
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1407
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1408
|
+
nav: "queries"
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
for (const flow of ctx.flows) {
|
|
1413
|
+
if (!flow.requests || flow.requests.length < 2) continue;
|
|
1414
|
+
let authMs = 0;
|
|
1415
|
+
let totalMs = 0;
|
|
1416
|
+
for (const r of flow.requests) {
|
|
1417
|
+
const dur = r.pollingDurationMs ?? r.durationMs;
|
|
1418
|
+
totalMs += dur;
|
|
1419
|
+
if (AUTH_CATEGORIES.has(r.category ?? "")) authMs += dur;
|
|
1420
|
+
}
|
|
1421
|
+
if (totalMs > 0 && authMs > 0) {
|
|
1422
|
+
const pct = Math.round(authMs / totalMs * 100);
|
|
1423
|
+
if (pct >= AUTH_OVERHEAD_PCT) {
|
|
1424
|
+
insights.push({
|
|
1425
|
+
severity: "warning",
|
|
1426
|
+
type: "auth-overhead",
|
|
1427
|
+
title: "Auth Overhead",
|
|
1428
|
+
desc: `${flow.label} \u2014 ${pct}% of time (${formatDuration(authMs)}) spent in auth/middleware`,
|
|
1429
|
+
hint: "Auth checks consume a significant portion of this action. If using a third-party auth provider, check if session caching can reduce roundtrips.",
|
|
1430
|
+
nav: "actions"
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
const selectStarSeen = /* @__PURE__ */ new Map();
|
|
1436
|
+
for (const [, reqQueries] of queriesByReq) {
|
|
1437
|
+
for (const q of reqQueries) {
|
|
1438
|
+
if (!q.sql) continue;
|
|
1439
|
+
const isSelectStar = /^SELECT\s+\*/i.test(q.sql.trim()) || /\.\*\s+FROM/i.test(q.sql);
|
|
1440
|
+
if (!isSelectStar) continue;
|
|
1441
|
+
const info = getQueryInfo(q);
|
|
1442
|
+
const key = info.table || "unknown";
|
|
1443
|
+
selectStarSeen.set(key, (selectStarSeen.get(key) ?? 0) + 1);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
for (const [table, count] of selectStarSeen) {
|
|
1447
|
+
if (count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1448
|
+
insights.push({
|
|
1449
|
+
severity: "warning",
|
|
1450
|
+
type: "select-star",
|
|
1451
|
+
title: "SELECT * Query",
|
|
1452
|
+
desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1453
|
+
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage.",
|
|
1454
|
+
nav: "queries"
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
const highRowSeen = /* @__PURE__ */ new Map();
|
|
1458
|
+
for (const [, reqQueries] of queriesByReq) {
|
|
1459
|
+
for (const q of reqQueries) {
|
|
1460
|
+
if (!q.rowCount || q.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1461
|
+
const info = getQueryInfo(q);
|
|
1462
|
+
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1463
|
+
let entry = highRowSeen.get(key);
|
|
1464
|
+
if (!entry) {
|
|
1465
|
+
entry = { max: 0, count: 0 };
|
|
1466
|
+
highRowSeen.set(key, entry);
|
|
1467
|
+
}
|
|
1468
|
+
entry.count++;
|
|
1469
|
+
if (q.rowCount > entry.max) entry.max = q.rowCount;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
for (const [key, hrs] of highRowSeen) {
|
|
1473
|
+
if (hrs.count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1474
|
+
insights.push({
|
|
1475
|
+
severity: "warning",
|
|
1476
|
+
type: "high-rows",
|
|
1477
|
+
title: "Large Result Set",
|
|
1478
|
+
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
|
|
1479
|
+
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.",
|
|
1480
|
+
nav: "queries"
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
for (const [ep, g] of endpointGroups) {
|
|
1484
|
+
if (g.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1485
|
+
const avgSize = Math.round(g.totalSize / g.total);
|
|
1486
|
+
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
1487
|
+
insights.push({
|
|
1488
|
+
severity: "info",
|
|
1489
|
+
type: "large-response",
|
|
1490
|
+
title: "Large Response",
|
|
1491
|
+
desc: `${ep} \u2014 avg ${formatSize(avgSize)} response`,
|
|
1492
|
+
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
|
|
1493
|
+
nav: "requests"
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
if (ctx.securityFindings) {
|
|
1498
|
+
for (const f of ctx.securityFindings) {
|
|
1499
|
+
insights.push({
|
|
1500
|
+
severity: f.severity,
|
|
1501
|
+
type: "security",
|
|
1502
|
+
title: f.title,
|
|
1503
|
+
desc: f.desc,
|
|
1504
|
+
hint: f.hint,
|
|
1505
|
+
nav: "security"
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
1510
|
+
insights.sort((a, b) => (severityOrder[a.severity] ?? 2) - (severityOrder[b.severity] ?? 2));
|
|
1511
|
+
return insights;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// src/analysis/engine.ts
|
|
1515
|
+
var AnalysisEngine = class {
|
|
1516
|
+
constructor(debounceMs = 300) {
|
|
1517
|
+
this.debounceMs = debounceMs;
|
|
1518
|
+
this.scanner = createDefaultScanner();
|
|
1519
|
+
this.boundRequestListener = () => this.scheduleRecompute();
|
|
1520
|
+
this.boundQueryListener = () => this.scheduleRecompute();
|
|
1521
|
+
this.boundErrorListener = () => this.scheduleRecompute();
|
|
1522
|
+
this.boundLogListener = () => this.scheduleRecompute();
|
|
1523
|
+
}
|
|
1524
|
+
scanner;
|
|
1525
|
+
cachedInsights = [];
|
|
1526
|
+
cachedFindings = [];
|
|
1527
|
+
debounceTimer = null;
|
|
1528
|
+
listeners = [];
|
|
1529
|
+
boundRequestListener;
|
|
1530
|
+
boundQueryListener;
|
|
1531
|
+
boundErrorListener;
|
|
1532
|
+
boundLogListener;
|
|
1533
|
+
start() {
|
|
1534
|
+
onRequest(this.boundRequestListener);
|
|
1535
|
+
defaultQueryStore.onEntry(this.boundQueryListener);
|
|
1536
|
+
defaultErrorStore.onEntry(this.boundErrorListener);
|
|
1537
|
+
defaultLogStore.onEntry(this.boundLogListener);
|
|
1538
|
+
}
|
|
1539
|
+
stop() {
|
|
1540
|
+
offRequest(this.boundRequestListener);
|
|
1541
|
+
defaultQueryStore.offEntry(this.boundQueryListener);
|
|
1542
|
+
defaultErrorStore.offEntry(this.boundErrorListener);
|
|
1543
|
+
defaultLogStore.offEntry(this.boundLogListener);
|
|
1544
|
+
if (this.debounceTimer) {
|
|
1545
|
+
clearTimeout(this.debounceTimer);
|
|
1546
|
+
this.debounceTimer = null;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
onUpdate(fn) {
|
|
1550
|
+
this.listeners.push(fn);
|
|
1551
|
+
}
|
|
1552
|
+
offUpdate(fn) {
|
|
1553
|
+
const idx = this.listeners.indexOf(fn);
|
|
1554
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
1555
|
+
}
|
|
1556
|
+
getInsights() {
|
|
1557
|
+
return this.cachedInsights;
|
|
1558
|
+
}
|
|
1559
|
+
getFindings() {
|
|
1560
|
+
return this.cachedFindings;
|
|
1561
|
+
}
|
|
1562
|
+
scheduleRecompute() {
|
|
1563
|
+
if (this.debounceTimer) return;
|
|
1564
|
+
this.debounceTimer = setTimeout(() => {
|
|
1565
|
+
this.debounceTimer = null;
|
|
1566
|
+
this.recompute();
|
|
1567
|
+
}, this.debounceMs);
|
|
1568
|
+
}
|
|
1569
|
+
recompute() {
|
|
1570
|
+
const requests = getRequests();
|
|
1571
|
+
const queries = defaultQueryStore.getAll();
|
|
1572
|
+
const errors = defaultErrorStore.getAll();
|
|
1573
|
+
const logs = defaultLogStore.getAll();
|
|
1574
|
+
const flows = groupRequestsIntoFlows(requests);
|
|
1575
|
+
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
1576
|
+
this.cachedInsights = computeInsights({
|
|
1577
|
+
requests,
|
|
1578
|
+
queries,
|
|
1579
|
+
errors,
|
|
1580
|
+
flows,
|
|
1581
|
+
securityFindings: this.cachedFindings
|
|
1582
|
+
});
|
|
1583
|
+
for (const fn of this.listeners) {
|
|
1584
|
+
try {
|
|
1585
|
+
fn(this.cachedInsights, this.cachedFindings);
|
|
1586
|
+
} catch {
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
// src/index.ts
|
|
1593
|
+
var VERSION = "0.6.0";
|
|
1594
|
+
export {
|
|
1595
|
+
AdapterRegistry,
|
|
1596
|
+
AnalysisEngine,
|
|
1597
|
+
SecurityScanner,
|
|
1598
|
+
VERSION,
|
|
1599
|
+
computeInsights,
|
|
1600
|
+
createDefaultScanner,
|
|
1601
|
+
createProxyServer,
|
|
1602
|
+
detectProject
|
|
1603
|
+
};
|