brakit 0.6.0 → 0.6.2
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 +16 -2
- package/dist/bin/brakit.js +646 -803
- package/dist/index.d.ts +1 -1
- package/dist/index.js +250 -35
- package/package.json +1 -1
package/dist/bin/brakit.js
CHANGED
|
@@ -32,6 +32,7 @@ var DASHBOARD_API_ACTIVITY = "/__brakit/api/activity";
|
|
|
32
32
|
var DASHBOARD_API_METRICS_LIVE = "/__brakit/api/metrics/live";
|
|
33
33
|
var DASHBOARD_API_INSIGHTS = "/__brakit/api/insights";
|
|
34
34
|
var DASHBOARD_API_SECURITY = "/__brakit/api/security";
|
|
35
|
+
var DASHBOARD_API_TAB = "/__brakit/api/tab";
|
|
35
36
|
|
|
36
37
|
// src/constants/limits.ts
|
|
37
38
|
var MAX_REQUEST_ENTRIES = 1e3;
|
|
@@ -50,7 +51,6 @@ var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
|
50
51
|
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
51
52
|
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
52
53
|
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
53
|
-
var AUTH_OVERHEAD_PCT = 30;
|
|
54
54
|
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
55
55
|
var CROSS_ENDPOINT_PCT = 50;
|
|
56
56
|
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
@@ -58,6 +58,9 @@ var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
|
58
58
|
var LARGE_RESPONSE_BYTES = 51200;
|
|
59
59
|
var HIGH_ROW_COUNT = 100;
|
|
60
60
|
var OVERFETCH_MIN_REQUESTS = 2;
|
|
61
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
62
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
63
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
61
64
|
|
|
62
65
|
// src/constants/transport.ts
|
|
63
66
|
var SSE_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
@@ -161,7 +164,7 @@ var offRequest = (fn) => defaultStore.offRequest(fn);
|
|
|
161
164
|
|
|
162
165
|
// src/proxy/handler.ts
|
|
163
166
|
function proxyRequest(clientReq, clientRes, config) {
|
|
164
|
-
const
|
|
167
|
+
const startTime2 = performance.now();
|
|
165
168
|
const method = clientReq.method ?? "GET";
|
|
166
169
|
const requestId = randomUUID();
|
|
167
170
|
const shouldCaptureBody = method !== "GET" && method !== "HEAD";
|
|
@@ -191,7 +194,7 @@ function proxyRequest(clientReq, clientRes, config) {
|
|
|
191
194
|
clientReq,
|
|
192
195
|
clientRes,
|
|
193
196
|
proxyRes,
|
|
194
|
-
|
|
197
|
+
startTime2,
|
|
195
198
|
shouldCaptureBody ? bodyChunks : [],
|
|
196
199
|
config,
|
|
197
200
|
requestId
|
|
@@ -214,7 +217,7 @@ function proxyRequest(clientReq, clientRes, config) {
|
|
|
214
217
|
});
|
|
215
218
|
clientReq.pipe(proxyReq);
|
|
216
219
|
}
|
|
217
|
-
function handleProxyResponse(clientReq, clientRes, proxyRes,
|
|
220
|
+
function handleProxyResponse(clientReq, clientRes, proxyRes, startTime2, bodyChunks, config, requestId) {
|
|
218
221
|
const responseChunks = [];
|
|
219
222
|
let responseSize = 0;
|
|
220
223
|
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
@@ -239,7 +242,7 @@ function handleProxyResponse(clientReq, clientRes, proxyRes, startTime, bodyChun
|
|
|
239
242
|
responseHeaders: proxyRes.headers,
|
|
240
243
|
responseBody,
|
|
241
244
|
responseContentType: proxyRes.headers["content-type"] ?? "",
|
|
242
|
-
startTime,
|
|
245
|
+
startTime: startTime2,
|
|
243
246
|
config
|
|
244
247
|
});
|
|
245
248
|
});
|
|
@@ -575,7 +578,7 @@ function buildFlow(rawRequests) {
|
|
|
575
578
|
markDuplicates(rawRequests);
|
|
576
579
|
const requests = collapsePolling(rawRequests);
|
|
577
580
|
const first = requests[0];
|
|
578
|
-
const
|
|
581
|
+
const startTime2 = first.startedAt;
|
|
579
582
|
const endTime = Math.max(
|
|
580
583
|
...requests.map(
|
|
581
584
|
(r) => r.pollingDurationMs ? r.startedAt + r.pollingDurationMs : r.startedAt + r.durationMs
|
|
@@ -589,8 +592,8 @@ function buildFlow(rawRequests) {
|
|
|
589
592
|
id: randomUUID2(),
|
|
590
593
|
label: deriveFlowLabel(requests, sourcePage),
|
|
591
594
|
requests,
|
|
592
|
-
startTime,
|
|
593
|
-
totalDurationMs: Math.round(endTime -
|
|
595
|
+
startTime: startTime2,
|
|
596
|
+
totalDurationMs: Math.round(endTime - startTime2),
|
|
594
597
|
hasErrors: requests.some((r) => r.statusCode >= 400),
|
|
595
598
|
warnings: detectWarnings(rawRequests),
|
|
596
599
|
sourcePage,
|
|
@@ -1224,62 +1227,70 @@ function createSecurityHandler(engine) {
|
|
|
1224
1227
|
}
|
|
1225
1228
|
|
|
1226
1229
|
// src/dashboard/sse.ts
|
|
1227
|
-
function
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1230
|
+
function createSSEHandler(engine) {
|
|
1231
|
+
return (req, res) => {
|
|
1232
|
+
res.writeHead(200, {
|
|
1233
|
+
"content-type": "text/event-stream",
|
|
1234
|
+
"cache-control": "no-cache",
|
|
1235
|
+
connection: "keep-alive",
|
|
1236
|
+
"access-control-allow-origin": "*"
|
|
1237
|
+
});
|
|
1238
|
+
res.write(":ok\n\n");
|
|
1239
|
+
const writeEvent = (eventType, data) => {
|
|
1240
|
+
if (res.destroyed) return;
|
|
1241
|
+
if (eventType) {
|
|
1242
|
+
res.write(`event: ${eventType}
|
|
1239
1243
|
data: ${data}
|
|
1240
1244
|
|
|
1241
1245
|
`);
|
|
1242
|
-
|
|
1243
|
-
|
|
1246
|
+
} else {
|
|
1247
|
+
res.write(`data: ${data}
|
|
1244
1248
|
|
|
1245
1249
|
`);
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
const requestListener = (traced) => {
|
|
1253
|
+
writeEvent(null, JSON.stringify(traced));
|
|
1254
|
+
};
|
|
1255
|
+
const fetchListener = (entry) => {
|
|
1256
|
+
writeEvent("fetch", JSON.stringify(entry));
|
|
1257
|
+
};
|
|
1258
|
+
const logListener = (entry) => {
|
|
1259
|
+
writeEvent("log", JSON.stringify(entry));
|
|
1260
|
+
};
|
|
1261
|
+
const errorListener = (entry) => {
|
|
1262
|
+
writeEvent("error_event", JSON.stringify(entry));
|
|
1263
|
+
};
|
|
1264
|
+
const queryListener = (entry) => {
|
|
1265
|
+
writeEvent("query", JSON.stringify(entry));
|
|
1266
|
+
};
|
|
1267
|
+
const analysisListener = engine ? (insights, findings) => {
|
|
1268
|
+
writeEvent("insights", JSON.stringify(insights));
|
|
1269
|
+
writeEvent("security", JSON.stringify(findings));
|
|
1270
|
+
} : void 0;
|
|
1271
|
+
onRequest(requestListener);
|
|
1272
|
+
defaultFetchStore.onEntry(fetchListener);
|
|
1273
|
+
defaultLogStore.onEntry(logListener);
|
|
1274
|
+
defaultErrorStore.onEntry(errorListener);
|
|
1275
|
+
defaultQueryStore.onEntry(queryListener);
|
|
1276
|
+
if (engine && analysisListener) engine.onUpdate(analysisListener);
|
|
1277
|
+
const heartbeat = setInterval(() => {
|
|
1278
|
+
if (res.destroyed) {
|
|
1279
|
+
clearInterval(heartbeat);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
res.write(":heartbeat\n\n");
|
|
1283
|
+
}, SSE_HEARTBEAT_INTERVAL_MS);
|
|
1284
|
+
req.on("close", () => {
|
|
1270
1285
|
clearInterval(heartbeat);
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
defaultLogStore.offEntry(logListener);
|
|
1280
|
-
defaultErrorStore.offEntry(errorListener);
|
|
1281
|
-
defaultQueryStore.offEntry(queryListener);
|
|
1282
|
-
});
|
|
1286
|
+
offRequest(requestListener);
|
|
1287
|
+
defaultFetchStore.offEntry(fetchListener);
|
|
1288
|
+
defaultLogStore.offEntry(logListener);
|
|
1289
|
+
defaultErrorStore.offEntry(errorListener);
|
|
1290
|
+
defaultQueryStore.offEntry(queryListener);
|
|
1291
|
+
if (engine && analysisListener) engine.offUpdate(analysisListener);
|
|
1292
|
+
});
|
|
1293
|
+
};
|
|
1283
1294
|
}
|
|
1284
1295
|
|
|
1285
1296
|
// src/dashboard/styles/base.ts
|
|
@@ -1916,7 +1927,6 @@ var HEALTH_GOOD_MS = 300;
|
|
|
1916
1927
|
var HEALTH_OK_MS = 800;
|
|
1917
1928
|
var HEALTH_SLOW_MS = 2e3;
|
|
1918
1929
|
var SLOW_QUERY_THRESHOLD_MS = 100;
|
|
1919
|
-
var AUTH_SLOW_MS = 500;
|
|
1920
1930
|
var AUTH_SKIP_CATEGORIES = `{ 'auth-handshake': 1, 'auth-check': 1, 'middleware': 1 }`;
|
|
1921
1931
|
var TIMELINE_CACHE_MAX = 50;
|
|
1922
1932
|
var TIMELINE_ROOT_MARGIN = "'200px'";
|
|
@@ -2116,22 +2126,6 @@ function getSqlUtils() {
|
|
|
2116
2126
|
return `
|
|
2117
2127
|
var QUERY_OP_COLORS = ${QUERY_OP_COLORS};
|
|
2118
2128
|
|
|
2119
|
-
// Extracts operation and table name from raw SQL.
|
|
2120
|
-
// Handles Postgres schema-qualified names ("public"."table") and positional params ($1).
|
|
2121
|
-
function simplifySQL(sql) {
|
|
2122
|
-
if (!sql) return { op: '?', table: '' };
|
|
2123
|
-
var trimmed = sql.trim();
|
|
2124
|
-
var op = trimmed.split(/\\s+/)[0].toUpperCase();
|
|
2125
|
-
|
|
2126
|
-
if (/SELECT\\s+COUNT/i.test(trimmed)) {
|
|
2127
|
-
var countTable = trimmed.match(/FROM\\s+"?\\w+"?\\."?(\\w+)"?/i);
|
|
2128
|
-
return { op: 'COUNT', table: countTable ? countTable[1] : '' };
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
var tableMatch = trimmed.match(/(?:FROM|INTO|UPDATE)\\s+"?\\w+"?\\."?(\\w+)"?/i);
|
|
2132
|
-
return { op: op, table: tableMatch ? tableMatch[1] : '' };
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
2129
|
function truncateSQL(sql, max) {
|
|
2136
2130
|
if (!sql) return '';
|
|
2137
2131
|
var clean = sql.replace(/"public"\\./g, '').replace(/"/g, '');
|
|
@@ -2143,16 +2137,6 @@ function getSqlUtils() {
|
|
|
2143
2137
|
if (ms === 0) return '<1ms';
|
|
2144
2138
|
return formatDuration(ms);
|
|
2145
2139
|
}
|
|
2146
|
-
|
|
2147
|
-
// Normalizes SQL by replacing literal values with placeholders.
|
|
2148
|
-
// Used to group queries by "shape" for N+1 and cross-endpoint detection.
|
|
2149
|
-
function normalizeQueryParams(sql) {
|
|
2150
|
-
if (!sql) return null;
|
|
2151
|
-
var n = sql.replace(/'[^']*'/g, '?');
|
|
2152
|
-
n = n.replace(/\\b\\d+(\\.\\d+)?\\b/g, '?');
|
|
2153
|
-
n = n.replace(/\\$\\d+/g, '?');
|
|
2154
|
-
return n;
|
|
2155
|
-
}
|
|
2156
2140
|
`;
|
|
2157
2141
|
}
|
|
2158
2142
|
|
|
@@ -2291,7 +2275,6 @@ function getFlowInsights() {
|
|
|
2291
2275
|
var warnings = [];
|
|
2292
2276
|
var duplicates = [];
|
|
2293
2277
|
var seen = new Map();
|
|
2294
|
-
var authMs = 0;
|
|
2295
2278
|
var totalMs = 0;
|
|
2296
2279
|
for (var i = 0; i < reqs.length; i++) {
|
|
2297
2280
|
var req = reqs[i];
|
|
@@ -2300,10 +2283,6 @@ function getFlowInsights() {
|
|
|
2300
2283
|
totalMs += dur;
|
|
2301
2284
|
|
|
2302
2285
|
if (skipCats[req.category]) {
|
|
2303
|
-
authMs += dur;
|
|
2304
|
-
if (dur > ${AUTH_SLOW_MS}) {
|
|
2305
|
-
warnings.push('Slow auth: ' + label + ' took ' + formatDuration(dur));
|
|
2306
|
-
}
|
|
2307
2286
|
continue;
|
|
2308
2287
|
}
|
|
2309
2288
|
|
|
@@ -2325,13 +2304,6 @@ function getFlowInsights() {
|
|
|
2325
2304
|
successes.push(label);
|
|
2326
2305
|
}
|
|
2327
2306
|
|
|
2328
|
-
if (totalMs > 0 && authMs > 0) {
|
|
2329
|
-
var authPct = Math.round((authMs / totalMs) * 100);
|
|
2330
|
-
if (authPct >= ${AUTH_OVERHEAD_PCT}) {
|
|
2331
|
-
warnings.unshift('Auth overhead: ' + authPct + '% of this action (' + formatDuration(authMs) + ') is spent in auth/middleware');
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
2307
|
for (var d of seen.values()) duplicates.push(d);
|
|
2336
2308
|
var tip = '';
|
|
2337
2309
|
if (duplicates.length > 0) {
|
|
@@ -2822,7 +2794,7 @@ function getQueriesView() {
|
|
|
2822
2794
|
var row = document.createElement('div');
|
|
2823
2795
|
row.className = 'req-row query-row tel-clickable';
|
|
2824
2796
|
|
|
2825
|
-
var info =
|
|
2797
|
+
var info = { op: (q.normalizedOp || q.operation || '?').toUpperCase(), table: q.table || q.model || '' };
|
|
2826
2798
|
var opColor = QUERY_OP_COLORS[info.op] || 'var(--text-dim)';
|
|
2827
2799
|
var slowCls = q.durationMs > ${SLOW_QUERY_THRESHOLD_MS} ? ' query-slow' : '';
|
|
2828
2800
|
var preview = q.sql || (info.op + ' ' + info.table);
|
|
@@ -2976,7 +2948,7 @@ function getTimelineView() {
|
|
|
2976
2948
|
'<span class="tl-event-dur">' + formatDuration(d.durationMs) + '</span>';
|
|
2977
2949
|
}
|
|
2978
2950
|
if (evt.type === 'query') {
|
|
2979
|
-
var info =
|
|
2951
|
+
var info = { op: (d.normalizedOp || d.operation || '?').toUpperCase(), table: d.table || d.model || '' };
|
|
2980
2952
|
var opColor = QUERY_OP_COLORS[info.op] || 'var(--text-dim)';
|
|
2981
2953
|
return '<span class="tl-event-summary"><span style="color:' + opColor + ';font-weight:600">' + escHtml(info.op) + '</span> ' + escHtml(info.table) + '</span>' +
|
|
2982
2954
|
'<span class="tl-event-dur">' + queryDuration(d.durationMs) + '</span>';
|
|
@@ -3490,389 +3462,6 @@ function getGraphView() {
|
|
|
3490
3462
|
`;
|
|
3491
3463
|
}
|
|
3492
3464
|
|
|
3493
|
-
// src/dashboard/client/views/overview/insights.ts
|
|
3494
|
-
function getOverviewInsights() {
|
|
3495
|
-
return `
|
|
3496
|
-
function computeInsights() {
|
|
3497
|
-
var insights = [];
|
|
3498
|
-
|
|
3499
|
-
var nonStatic = state.requests.filter(function(r) {
|
|
3500
|
-
return !r.isStatic && (!r.path || r.path.indexOf('${DASHBOARD_PREFIX}') !== 0);
|
|
3501
|
-
});
|
|
3502
|
-
|
|
3503
|
-
// N+1: same query shape with different parameter values in a single request
|
|
3504
|
-
var queriesByReq = {};
|
|
3505
|
-
for (var qi = 0; qi < state.queries.length; qi++) {
|
|
3506
|
-
var q = state.queries[qi];
|
|
3507
|
-
if (!q.parentRequestId) continue;
|
|
3508
|
-
if (!queriesByReq[q.parentRequestId]) queriesByReq[q.parentRequestId] = [];
|
|
3509
|
-
queriesByReq[q.parentRequestId].push(q);
|
|
3510
|
-
}
|
|
3511
|
-
|
|
3512
|
-
var reqById = {};
|
|
3513
|
-
for (var ri = 0; ri < nonStatic.length; ri++) {
|
|
3514
|
-
reqById[nonStatic[ri].id] = nonStatic[ri];
|
|
3515
|
-
}
|
|
3516
|
-
|
|
3517
|
-
var n1Seen = {};
|
|
3518
|
-
for (var reqId in queriesByReq) {
|
|
3519
|
-
var reqQueries = queriesByReq[reqId];
|
|
3520
|
-
var req = reqById[reqId];
|
|
3521
|
-
if (!req) continue;
|
|
3522
|
-
var endpoint = req.method + ' ' + req.path;
|
|
3523
|
-
|
|
3524
|
-
var shapeGroups = {};
|
|
3525
|
-
for (var tqi = 0; tqi < reqQueries.length; tqi++) {
|
|
3526
|
-
var tq = reqQueries[tqi];
|
|
3527
|
-
var normalized = tq.sql ? normalizeQueryParams(tq.sql) : null;
|
|
3528
|
-
var shape = normalized || ((tq.operation || '?') + ':' + (tq.model || ''));
|
|
3529
|
-
if (!shapeGroups[shape]) shapeGroups[shape] = { count: 0, distinctSql: {}, first: tq };
|
|
3530
|
-
shapeGroups[shape].count++;
|
|
3531
|
-
var sqlKey = tq.sql || shape;
|
|
3532
|
-
shapeGroups[shape].distinctSql[sqlKey] = 1;
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
for (var shape in shapeGroups) {
|
|
3536
|
-
var sg = shapeGroups[shape];
|
|
3537
|
-
var distinctCount = Object.keys(sg.distinctSql).length;
|
|
3538
|
-
if (sg.count > ${N1_QUERY_THRESHOLD} && distinctCount > 1) {
|
|
3539
|
-
var info = sg.first.sql ? simplifySQL(sg.first.sql) : { op: sg.first.operation || '?', table: sg.first.model || '' };
|
|
3540
|
-
var key = endpoint + ':' + info.op + ':' + (info.table || 'unknown');
|
|
3541
|
-
if (!n1Seen[key]) {
|
|
3542
|
-
n1Seen[key] = true;
|
|
3543
|
-
insights.push({
|
|
3544
|
-
severity: 'critical',
|
|
3545
|
-
type: 'n1',
|
|
3546
|
-
title: 'N+1 Query Pattern',
|
|
3547
|
-
desc: '<strong>' + escHtml(endpoint) + '</strong> runs ' + sg.count + 'x <strong>' + escHtml(info.op + ' ' + info.table) + '</strong> with different params in a single request',
|
|
3548
|
-
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.',
|
|
3549
|
-
nav: 'queries'
|
|
3550
|
-
});
|
|
3551
|
-
}
|
|
3552
|
-
}
|
|
3553
|
-
}
|
|
3554
|
-
}
|
|
3555
|
-
|
|
3556
|
-
// Cross-endpoint: same query shape appearing across many distinct endpoints
|
|
3557
|
-
var ceQueryMap = {};
|
|
3558
|
-
var ceAllEndpoints = {};
|
|
3559
|
-
for (var ceReqId in queriesByReq) {
|
|
3560
|
-
var ceReq = reqById[ceReqId];
|
|
3561
|
-
if (!ceReq) continue;
|
|
3562
|
-
var ceEndpoint = ceReq.method + ' ' + ceReq.path;
|
|
3563
|
-
ceAllEndpoints[ceEndpoint] = 1;
|
|
3564
|
-
var ceQueries = queriesByReq[ceReqId];
|
|
3565
|
-
var ceSeenInReq = {};
|
|
3566
|
-
for (var ceqi = 0; ceqi < ceQueries.length; ceqi++) {
|
|
3567
|
-
var ceq = ceQueries[ceqi];
|
|
3568
|
-
var ceNorm = ceq.sql ? normalizeQueryParams(ceq.sql) : null;
|
|
3569
|
-
var ceShape = ceNorm || ((ceq.operation || '?') + ':' + (ceq.model || ''));
|
|
3570
|
-
if (!ceQueryMap[ceShape]) ceQueryMap[ceShape] = { endpoints: {}, count: 0, first: ceq };
|
|
3571
|
-
ceQueryMap[ceShape].count++;
|
|
3572
|
-
if (!ceSeenInReq[ceShape]) {
|
|
3573
|
-
ceSeenInReq[ceShape] = true;
|
|
3574
|
-
ceQueryMap[ceShape].endpoints[ceEndpoint] = 1;
|
|
3575
|
-
}
|
|
3576
|
-
}
|
|
3577
|
-
}
|
|
3578
|
-
var ceTotalEndpoints = Object.keys(ceAllEndpoints).length;
|
|
3579
|
-
if (ceTotalEndpoints >= ${CROSS_ENDPOINT_MIN_ENDPOINTS}) {
|
|
3580
|
-
for (var ceShape in ceQueryMap) {
|
|
3581
|
-
var cem = ceQueryMap[ceShape];
|
|
3582
|
-
var ceEpCount = Object.keys(cem.endpoints).length;
|
|
3583
|
-
if (cem.count < ${CROSS_ENDPOINT_MIN_OCCURRENCES}) continue;
|
|
3584
|
-
if (ceEpCount < ${CROSS_ENDPOINT_MIN_ENDPOINTS}) continue;
|
|
3585
|
-
var cePct = Math.round((ceEpCount / ceTotalEndpoints) * 100);
|
|
3586
|
-
if (cePct < ${CROSS_ENDPOINT_PCT}) continue;
|
|
3587
|
-
var ceInfo = cem.first.sql ? simplifySQL(cem.first.sql) : { op: cem.first.operation || '?', table: cem.first.model || '' };
|
|
3588
|
-
var ceLabel = ceInfo.op + (ceInfo.table ? ' ' + ceInfo.table : '');
|
|
3589
|
-
var ceEpList = Object.keys(cem.endpoints);
|
|
3590
|
-
var ceDetailHtml = '';
|
|
3591
|
-
for (var ceEpi = 0; ceEpi < ceEpList.length; ceEpi++) {
|
|
3592
|
-
ceDetailHtml += '<div class="ov-detail-item">' + escHtml(ceEpList[ceEpi]) + '</div>';
|
|
3593
|
-
}
|
|
3594
|
-
insights.push({
|
|
3595
|
-
severity: 'warning',
|
|
3596
|
-
type: 'cross-endpoint',
|
|
3597
|
-
title: 'Repeated Query Across Endpoints',
|
|
3598
|
-
desc: '<strong>' + escHtml(ceLabel) + '</strong> runs on ' + ceEpCount + ' of ' + ceTotalEndpoints + ' endpoints (' + cePct + '%).',
|
|
3599
|
-
detail: '<div class="ov-detail-label">Affected endpoints:</div>' + ceDetailHtml,
|
|
3600
|
-
hint: 'This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.',
|
|
3601
|
-
nav: 'queries'
|
|
3602
|
-
});
|
|
3603
|
-
}
|
|
3604
|
-
}
|
|
3605
|
-
|
|
3606
|
-
// Redundant queries: exact same query (same params) fired 2+ times in one request
|
|
3607
|
-
// Only checks queries with actual SQL \u2014 ORM-only queries (operation:model) can't
|
|
3608
|
-
// distinguish different params, so we'd get false positives on N+1-style calls.
|
|
3609
|
-
var rqSeen = {};
|
|
3610
|
-
for (var rqReqId in queriesByReq) {
|
|
3611
|
-
var rqReq = reqById[rqReqId];
|
|
3612
|
-
if (!rqReq) continue;
|
|
3613
|
-
var rqEndpoint = rqReq.method + ' ' + rqReq.path;
|
|
3614
|
-
var rqQueries = queriesByReq[rqReqId];
|
|
3615
|
-
var rqExact = {};
|
|
3616
|
-
for (var rqi = 0; rqi < rqQueries.length; rqi++) {
|
|
3617
|
-
var rqq = rqQueries[rqi];
|
|
3618
|
-
if (!rqq.sql) continue;
|
|
3619
|
-
var rqKey = rqq.sql;
|
|
3620
|
-
if (!rqExact[rqKey]) rqExact[rqKey] = { count: 0, first: rqq };
|
|
3621
|
-
rqExact[rqKey].count++;
|
|
3622
|
-
}
|
|
3623
|
-
for (var rqk in rqExact) {
|
|
3624
|
-
var rqe = rqExact[rqk];
|
|
3625
|
-
if (rqe.count < ${REDUNDANT_QUERY_MIN_COUNT}) continue;
|
|
3626
|
-
var rqInfo = rqe.first.sql ? simplifySQL(rqe.first.sql) : { op: rqe.first.operation || '?', table: rqe.first.model || '' };
|
|
3627
|
-
var rqLabel = rqInfo.op + (rqInfo.table ? ' ' + rqInfo.table : '');
|
|
3628
|
-
var rqDedup = rqEndpoint + ':' + rqLabel;
|
|
3629
|
-
if (rqSeen[rqDedup]) continue;
|
|
3630
|
-
rqSeen[rqDedup] = true;
|
|
3631
|
-
insights.push({
|
|
3632
|
-
severity: 'warning',
|
|
3633
|
-
type: 'redundant-query',
|
|
3634
|
-
title: 'Redundant Query',
|
|
3635
|
-
desc: '<strong>' + escHtml(rqLabel) + '</strong> runs ' + rqe.count + 'x with identical params in <strong>' + escHtml(rqEndpoint) + '</strong>.',
|
|
3636
|
-
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.',
|
|
3637
|
-
nav: 'queries'
|
|
3638
|
-
});
|
|
3639
|
-
}
|
|
3640
|
-
}
|
|
3641
|
-
|
|
3642
|
-
if (state.errors.length > 0) {
|
|
3643
|
-
var errGroups = {};
|
|
3644
|
-
for (var ei = 0; ei < state.errors.length; ei++) {
|
|
3645
|
-
var eName = state.errors[ei].name || 'Error';
|
|
3646
|
-
errGroups[eName] = (errGroups[eName] || 0) + 1;
|
|
3647
|
-
}
|
|
3648
|
-
for (var errName in errGroups) {
|
|
3649
|
-
var cnt = errGroups[errName];
|
|
3650
|
-
insights.push({
|
|
3651
|
-
severity: 'critical',
|
|
3652
|
-
type: 'error',
|
|
3653
|
-
title: 'Unhandled Error',
|
|
3654
|
-
desc: '<strong>' + escHtml(errName) + '</strong> \u2014 occurred ' + cnt + ' time' + (cnt !== 1 ? 's' : ''),
|
|
3655
|
-
hint: 'Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.',
|
|
3656
|
-
nav: 'errors'
|
|
3657
|
-
});
|
|
3658
|
-
}
|
|
3659
|
-
}
|
|
3660
|
-
|
|
3661
|
-
var endpointGroups = {};
|
|
3662
|
-
for (var gi = 0; gi < nonStatic.length; gi++) {
|
|
3663
|
-
var r = nonStatic[gi];
|
|
3664
|
-
var ep = r.method + ' ' + r.path;
|
|
3665
|
-
if (!endpointGroups[ep]) endpointGroups[ep] = { total: 0, errors: 0, totalDuration: 0, queryCount: 0 };
|
|
3666
|
-
endpointGroups[ep].total++;
|
|
3667
|
-
if (r.statusCode >= 400) endpointGroups[ep].errors++;
|
|
3668
|
-
endpointGroups[ep].totalDuration += r.durationMs;
|
|
3669
|
-
endpointGroups[ep].queryCount += (queriesByReq[r.id] || []).length;
|
|
3670
|
-
}
|
|
3671
|
-
|
|
3672
|
-
for (var epKey in endpointGroups) {
|
|
3673
|
-
var g = endpointGroups[epKey];
|
|
3674
|
-
if (g.total < ${MIN_REQUESTS_FOR_INSIGHT}) continue;
|
|
3675
|
-
var errorRate = Math.round((g.errors / g.total) * 100);
|
|
3676
|
-
if (errorRate >= ${ERROR_RATE_THRESHOLD_PCT}) {
|
|
3677
|
-
insights.push({
|
|
3678
|
-
severity: 'critical',
|
|
3679
|
-
type: 'error-hotspot',
|
|
3680
|
-
title: 'Error Hotspot',
|
|
3681
|
-
desc: '<strong>' + escHtml(epKey) + '</strong> \u2014 ' + errorRate + '% error rate (' + g.errors + '/' + g.total + ' requests)',
|
|
3682
|
-
hint: 'This endpoint frequently returns errors. Check the response bodies for error details and stack traces.',
|
|
3683
|
-
nav: 'requests'
|
|
3684
|
-
});
|
|
3685
|
-
}
|
|
3686
|
-
}
|
|
3687
|
-
|
|
3688
|
-
var dupCounts = {};
|
|
3689
|
-
var flowCount = {};
|
|
3690
|
-
for (var fi = 0; fi < state.flows.length; fi++) {
|
|
3691
|
-
var flow = state.flows[fi];
|
|
3692
|
-
if (!flow.requests) continue;
|
|
3693
|
-
var seenInFlow = {};
|
|
3694
|
-
for (var fri = 0; fri < flow.requests.length; fri++) {
|
|
3695
|
-
var fr = flow.requests[fri];
|
|
3696
|
-
if (!fr.isDuplicate) continue;
|
|
3697
|
-
var dupKey = fr.method + ' ' + (fr.label || fr.path || fr.url);
|
|
3698
|
-
dupCounts[dupKey] = (dupCounts[dupKey] || 0) + 1;
|
|
3699
|
-
if (!seenInFlow[dupKey]) {
|
|
3700
|
-
seenInFlow[dupKey] = true;
|
|
3701
|
-
flowCount[dupKey] = (flowCount[dupKey] || 0) + 1;
|
|
3702
|
-
}
|
|
3703
|
-
}
|
|
3704
|
-
}
|
|
3705
|
-
|
|
3706
|
-
var dupEntries = [];
|
|
3707
|
-
for (var dk in dupCounts) dupEntries.push({ key: dk, count: dupCounts[dk], flows: flowCount[dk] || 0 });
|
|
3708
|
-
dupEntries.sort(function(a, b) { return b.count - a.count; });
|
|
3709
|
-
for (var di = 0; di < Math.min(dupEntries.length, 3); di++) {
|
|
3710
|
-
var d = dupEntries[di];
|
|
3711
|
-
insights.push({
|
|
3712
|
-
severity: 'warning',
|
|
3713
|
-
type: 'duplicate',
|
|
3714
|
-
title: 'Duplicate API Call',
|
|
3715
|
-
desc: '<strong>' + escHtml(d.key) + '</strong> loaded ' + d.count + 'x as duplicate across ' + d.flows + ' action' + (d.flows !== 1 ? 's' : ''),
|
|
3716
|
-
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.',
|
|
3717
|
-
nav: 'actions'
|
|
3718
|
-
});
|
|
3719
|
-
}
|
|
3720
|
-
|
|
3721
|
-
for (var sepKey in endpointGroups) {
|
|
3722
|
-
var sg = endpointGroups[sepKey];
|
|
3723
|
-
if (sg.total < ${MIN_REQUESTS_FOR_INSIGHT}) continue;
|
|
3724
|
-
var avgMs = Math.round(sg.totalDuration / sg.total);
|
|
3725
|
-
if (avgMs >= ${SLOW_ENDPOINT_THRESHOLD_MS}) {
|
|
3726
|
-
insights.push({
|
|
3727
|
-
severity: 'warning',
|
|
3728
|
-
type: 'slow',
|
|
3729
|
-
title: 'Slow Endpoint',
|
|
3730
|
-
desc: '<strong>' + escHtml(sepKey) + '</strong> \u2014 avg ' + formatDuration(avgMs) + ' across ' + sg.total + ' request' + (sg.total !== 1 ? 's' : ''),
|
|
3731
|
-
hint: 'Consistently slow responses hurt user experience. Check the Queries tab to see if database queries are the bottleneck.',
|
|
3732
|
-
nav: 'requests'
|
|
3733
|
-
});
|
|
3734
|
-
}
|
|
3735
|
-
}
|
|
3736
|
-
|
|
3737
|
-
for (var qhKey in endpointGroups) {
|
|
3738
|
-
var qg = endpointGroups[qhKey];
|
|
3739
|
-
if (qg.total < ${MIN_REQUESTS_FOR_INSIGHT}) continue;
|
|
3740
|
-
var avgQueries = Math.round(qg.queryCount / qg.total);
|
|
3741
|
-
if (avgQueries > ${HIGH_QUERY_COUNT_PER_REQ}) {
|
|
3742
|
-
insights.push({
|
|
3743
|
-
severity: 'warning',
|
|
3744
|
-
type: 'query-heavy',
|
|
3745
|
-
title: 'Query-Heavy Endpoint',
|
|
3746
|
-
desc: '<strong>' + escHtml(qhKey) + '</strong> \u2014 avg ' + avgQueries + ' queries/request',
|
|
3747
|
-
hint: 'Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.',
|
|
3748
|
-
nav: 'queries'
|
|
3749
|
-
});
|
|
3750
|
-
}
|
|
3751
|
-
}
|
|
3752
|
-
|
|
3753
|
-
var authCats = ${AUTH_SKIP_CATEGORIES};
|
|
3754
|
-
for (var afi = 0; afi < state.flows.length; afi++) {
|
|
3755
|
-
var af = state.flows[afi];
|
|
3756
|
-
if (!af.requests || af.requests.length < 2) continue;
|
|
3757
|
-
var afAuthMs = 0;
|
|
3758
|
-
var afTotalMs = 0;
|
|
3759
|
-
for (var ari = 0; ari < af.requests.length; ari++) {
|
|
3760
|
-
var ar = af.requests[ari];
|
|
3761
|
-
var arDur = ar.pollingDurationMs || ar.durationMs;
|
|
3762
|
-
afTotalMs += arDur;
|
|
3763
|
-
if (authCats[ar.category]) afAuthMs += arDur;
|
|
3764
|
-
}
|
|
3765
|
-
if (afTotalMs > 0 && afAuthMs > 0) {
|
|
3766
|
-
var afPct = Math.round((afAuthMs / afTotalMs) * 100);
|
|
3767
|
-
if (afPct >= ${AUTH_OVERHEAD_PCT}) {
|
|
3768
|
-
insights.push({
|
|
3769
|
-
severity: 'warning',
|
|
3770
|
-
type: 'auth-overhead',
|
|
3771
|
-
title: 'Auth Overhead',
|
|
3772
|
-
desc: '<strong>' + escHtml(af.label) + '</strong> \\u2014 ' + afPct + '% of time (' + formatDuration(afAuthMs) + ') spent in auth/middleware',
|
|
3773
|
-
hint: 'Auth checks consume a significant portion of this action. If using a third-party auth provider, check if session caching can reduce roundtrips.',
|
|
3774
|
-
nav: 'actions'
|
|
3775
|
-
});
|
|
3776
|
-
}
|
|
3777
|
-
}
|
|
3778
|
-
}
|
|
3779
|
-
|
|
3780
|
-
var selectStarSeen = {};
|
|
3781
|
-
for (var sqReqId in queriesByReq) {
|
|
3782
|
-
var sqQueries = queriesByReq[sqReqId];
|
|
3783
|
-
for (var sqi = 0; sqi < sqQueries.length; sqi++) {
|
|
3784
|
-
var sq = sqQueries[sqi];
|
|
3785
|
-
if (!sq.sql) continue;
|
|
3786
|
-
var sqlUp = sq.sql.trim();
|
|
3787
|
-
// Matches both "SELECT * FROM ..." and ORM-style "table.* FROM ..." patterns
|
|
3788
|
-
var isSelectStar = /^SELECT\\s+\\*/i.test(sqlUp) || /\\.\\*\\s+FROM/i.test(sqlUp);
|
|
3789
|
-
if (isSelectStar) {
|
|
3790
|
-
var sqInfo = simplifySQL(sq.sql);
|
|
3791
|
-
var sqKey = sqInfo.table || 'unknown';
|
|
3792
|
-
if (!selectStarSeen[sqKey]) {
|
|
3793
|
-
selectStarSeen[sqKey] = 0;
|
|
3794
|
-
}
|
|
3795
|
-
selectStarSeen[sqKey]++;
|
|
3796
|
-
}
|
|
3797
|
-
}
|
|
3798
|
-
}
|
|
3799
|
-
for (var ssKey in selectStarSeen) {
|
|
3800
|
-
if (selectStarSeen[ssKey] >= ${OVERFETCH_MIN_REQUESTS}) {
|
|
3801
|
-
insights.push({
|
|
3802
|
-
severity: 'warning',
|
|
3803
|
-
type: 'select-star',
|
|
3804
|
-
title: 'SELECT * Query',
|
|
3805
|
-
desc: '<strong>SELECT *</strong> on <strong>' + escHtml(ssKey) + '</strong> \\u2014 ' + selectStarSeen[ssKey] + ' occurrence' + (selectStarSeen[ssKey] !== 1 ? 's' : ''),
|
|
3806
|
-
hint: 'SELECT * fetches all columns including ones you don\\u2019t need. Specify only required columns to reduce data transfer and memory usage.',
|
|
3807
|
-
nav: 'queries'
|
|
3808
|
-
});
|
|
3809
|
-
}
|
|
3810
|
-
}
|
|
3811
|
-
|
|
3812
|
-
var highRowSeen = {};
|
|
3813
|
-
for (var hrReqId in queriesByReq) {
|
|
3814
|
-
var hrQueries = queriesByReq[hrReqId];
|
|
3815
|
-
for (var hri = 0; hri < hrQueries.length; hri++) {
|
|
3816
|
-
var hq = hrQueries[hri];
|
|
3817
|
-
if (hq.rowCount && hq.rowCount > ${HIGH_ROW_COUNT}) {
|
|
3818
|
-
var hrInfo = hq.sql ? simplifySQL(hq.sql) : { op: hq.operation || '?', table: hq.model || '' };
|
|
3819
|
-
var hrKey = hrInfo.op + ' ' + (hrInfo.table || 'unknown');
|
|
3820
|
-
if (!highRowSeen[hrKey]) highRowSeen[hrKey] = { max: 0, count: 0 };
|
|
3821
|
-
highRowSeen[hrKey].count++;
|
|
3822
|
-
if (hq.rowCount > highRowSeen[hrKey].max) highRowSeen[hrKey].max = hq.rowCount;
|
|
3823
|
-
}
|
|
3824
|
-
}
|
|
3825
|
-
}
|
|
3826
|
-
for (var hrk in highRowSeen) {
|
|
3827
|
-
var hrs = highRowSeen[hrk];
|
|
3828
|
-
if (hrs.count >= ${OVERFETCH_MIN_REQUESTS}) {
|
|
3829
|
-
insights.push({
|
|
3830
|
-
severity: 'warning',
|
|
3831
|
-
type: 'high-rows',
|
|
3832
|
-
title: 'Large Result Set',
|
|
3833
|
-
desc: '<strong>' + escHtml(hrk) + '</strong> returns ' + hrs.max + '+ rows (' + hrs.count + 'x)',
|
|
3834
|
-
hint: 'Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition.',
|
|
3835
|
-
nav: 'queries'
|
|
3836
|
-
});
|
|
3837
|
-
}
|
|
3838
|
-
}
|
|
3839
|
-
|
|
3840
|
-
for (var lrKey in endpointGroups) {
|
|
3841
|
-
var lr = endpointGroups[lrKey];
|
|
3842
|
-
if (lr.total < ${OVERFETCH_MIN_REQUESTS}) continue;
|
|
3843
|
-
var lrTotalSize = 0;
|
|
3844
|
-
for (var lri = 0; lri < nonStatic.length; lri++) {
|
|
3845
|
-
var lrr = nonStatic[lri];
|
|
3846
|
-
if ((lrr.method + ' ' + lrr.path) === lrKey) lrTotalSize += lrr.responseSize || 0;
|
|
3847
|
-
}
|
|
3848
|
-
var lrAvg = Math.round(lrTotalSize / lr.total);
|
|
3849
|
-
if (lrAvg > ${LARGE_RESPONSE_BYTES}) {
|
|
3850
|
-
insights.push({
|
|
3851
|
-
severity: 'info',
|
|
3852
|
-
type: 'large-response',
|
|
3853
|
-
title: 'Large Response',
|
|
3854
|
-
desc: '<strong>' + escHtml(lrKey) + '</strong> \\u2014 avg ' + formatSize(lrAvg) + ' response',
|
|
3855
|
-
hint: 'Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.',
|
|
3856
|
-
nav: 'requests'
|
|
3857
|
-
});
|
|
3858
|
-
}
|
|
3859
|
-
}
|
|
3860
|
-
|
|
3861
|
-
var secFindings = computeSecurityFindings();
|
|
3862
|
-
for (var si = 0; si < secFindings.length; si++) {
|
|
3863
|
-
insights.push(secFindings[si]);
|
|
3864
|
-
}
|
|
3865
|
-
|
|
3866
|
-
var severityOrder = { critical: 0, warning: 1, info: 2 };
|
|
3867
|
-
insights.sort(function(a, b) {
|
|
3868
|
-
return (severityOrder[a.severity] || 2) - (severityOrder[b.severity] || 2);
|
|
3869
|
-
});
|
|
3870
|
-
|
|
3871
|
-
return insights;
|
|
3872
|
-
}
|
|
3873
|
-
`;
|
|
3874
|
-
}
|
|
3875
|
-
|
|
3876
3465
|
// src/dashboard/client/views/overview/render.ts
|
|
3877
3466
|
function getOverviewRender() {
|
|
3878
3467
|
return `
|
|
@@ -3908,7 +3497,7 @@ function getOverviewRender() {
|
|
|
3908
3497
|
'<div class="ov-stat"><span class="ov-stat-value">' + state.fetches.length + '</span><span class="ov-stat-label">Fetches</span></div>';
|
|
3909
3498
|
container.appendChild(summary);
|
|
3910
3499
|
|
|
3911
|
-
var insights =
|
|
3500
|
+
var insights = state.insights || [];
|
|
3912
3501
|
|
|
3913
3502
|
if (insights.length === 0) {
|
|
3914
3503
|
var clear = document.createElement('div');
|
|
@@ -3985,275 +3574,7 @@ function getOverviewRender() {
|
|
|
3985
3574
|
|
|
3986
3575
|
// src/dashboard/client/views/overview/index.ts
|
|
3987
3576
|
function getOverviewView() {
|
|
3988
|
-
return
|
|
3989
|
-
}
|
|
3990
|
-
|
|
3991
|
-
// src/dashboard/client/rules/patterns.ts
|
|
3992
|
-
function getSecurityPatterns() {
|
|
3993
|
-
return `
|
|
3994
|
-
/** Response JSON keys that indicate secrets (password, api_key, etc.) */
|
|
3995
|
-
var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
|
|
3996
|
-
/** URL query params that are auth tokens and should be in headers instead */
|
|
3997
|
-
var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
|
|
3998
|
-
/** Framework-specific query params safe to appear in URLs (Clerk, OAuth, UTM) */
|
|
3999
|
-
var SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
|
|
4000
|
-
/** Node.js stack trace signature \u2014 presence in response body means stack trace leak */
|
|
4001
|
-
var STACK_TRACE_RE = /at\\s+.+\\(.+:\\d+:\\d+\\)|at\\s+Module\\._compile|at\\s+Object\\.<anonymous>|at\\s+processTicksAndRejections/;
|
|
4002
|
-
/** Database connection string pattern */
|
|
4003
|
-
var DB_CONN_RE = /(postgres|mysql|mongodb|redis):\\/\\//;
|
|
4004
|
-
/** Raw SQL fragment leak in response body */
|
|
4005
|
-
var SQL_FRAGMENT_RE = /\\b(SELECT\\s+[\\w.*]+\\s+FROM|INSERT\\s+INTO|UPDATE\\s+\\w+\\s+SET|DELETE\\s+FROM)\\b/i;
|
|
4006
|
-
/** Secret value assignment pattern in response body */
|
|
4007
|
-
var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\\s*[:=]\\s*["']?[A-Za-z0-9_\\-\\.\\+\\/]{8,}/;
|
|
4008
|
-
/** Secret value in console log output */
|
|
4009
|
-
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\\s*[:=]\\s*["']?[A-Za-z0-9_\\-\\.\\+\\/]{8,}/i;
|
|
4010
|
-
|
|
4011
|
-
var RULE_HINTS = {
|
|
4012
|
-
'exposed-secret': 'Never include secret fields in API responses. Strip sensitive fields before returning.',
|
|
4013
|
-
'token-in-url': 'Pass tokens in the Authorization header, not URL query parameters.',
|
|
4014
|
-
'stack-trace-leak': 'Use a custom error handler that returns generic messages in production.',
|
|
4015
|
-
'error-info-leak': 'Sanitize error responses. Return generic messages instead of internal details.',
|
|
4016
|
-
'sensitive-logs': 'Redact PII before logging. Never log passwords or tokens.',
|
|
4017
|
-
'cors-credentials': 'Cannot use credentials:true with origin:*. Specify explicit origins.',
|
|
4018
|
-
'insecure-cookie': 'Set HttpOnly and SameSite flags on all cookies.'
|
|
4019
|
-
};
|
|
4020
|
-
`;
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
// src/dashboard/client/rules/helpers.ts
|
|
4024
|
-
function getSecurityHelpers() {
|
|
4025
|
-
return `
|
|
4026
|
-
function tryParseJson(body) {
|
|
4027
|
-
if (!body) return null;
|
|
4028
|
-
try { return JSON.parse(body); } catch(e) { return null; }
|
|
4029
|
-
}
|
|
4030
|
-
|
|
4031
|
-
var MASKED_RE = /^\\*+$|\\[REDACTED\\]|\\[FILTERED\\]|CHANGE_ME|^x{3,}$/i;
|
|
4032
|
-
|
|
4033
|
-
function findSecretKeys(obj, prefix) {
|
|
4034
|
-
var found = [];
|
|
4035
|
-
if (!obj || typeof obj !== 'object') return found;
|
|
4036
|
-
if (Array.isArray(obj)) {
|
|
4037
|
-
for (var ai = 0; ai < Math.min(obj.length, 5); ai++) {
|
|
4038
|
-
found = found.concat(findSecretKeys(obj[ai], prefix));
|
|
4039
|
-
}
|
|
4040
|
-
return found;
|
|
4041
|
-
}
|
|
4042
|
-
for (var k in obj) {
|
|
4043
|
-
if (SECRET_KEYS.test(k) && obj[k] && typeof obj[k] === 'string' && obj[k].length >= 8 && !MASKED_RE.test(obj[k])) {
|
|
4044
|
-
found.push(k);
|
|
4045
|
-
}
|
|
4046
|
-
if (typeof obj[k] === 'object' && obj[k] !== null) {
|
|
4047
|
-
found = found.concat(findSecretKeys(obj[k], prefix + k + '.'));
|
|
4048
|
-
}
|
|
4049
|
-
}
|
|
4050
|
-
return found;
|
|
4051
|
-
}
|
|
4052
|
-
`;
|
|
4053
|
-
}
|
|
4054
|
-
|
|
4055
|
-
// src/dashboard/client/rules/critical.ts
|
|
4056
|
-
function getCriticalRules() {
|
|
4057
|
-
return `
|
|
4058
|
-
function ruleExposedSecret(requests, findings) {
|
|
4059
|
-
var seen = {};
|
|
4060
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4061
|
-
var r = requests[i];
|
|
4062
|
-
if (r.statusCode >= 400) continue;
|
|
4063
|
-
var parsed = tryParseJson(r.responseBody);
|
|
4064
|
-
if (!parsed) continue;
|
|
4065
|
-
var keys = findSecretKeys(parsed, '');
|
|
4066
|
-
if (keys.length === 0) continue;
|
|
4067
|
-
var ep = r.method + ' ' + r.path;
|
|
4068
|
-
var key = ep + ':' + keys.sort().join(',');
|
|
4069
|
-
if (seen[key]) { seen[key].count++; continue; }
|
|
4070
|
-
seen[key] = {
|
|
4071
|
-
severity: 'critical', type: 'security', rule: 'exposed-secret',
|
|
4072
|
-
title: 'Exposed Secret in Response',
|
|
4073
|
-
desc: '<strong>' + escHtml(ep) + '</strong> \u2014 response contains <strong>' + escHtml(keys.join(', ')) + '</strong> field' + (keys.length > 1 ? 's' : ''),
|
|
4074
|
-
nav: 'security', hint: RULE_HINTS['exposed-secret'], endpoint: ep, count: 1
|
|
4075
|
-
};
|
|
4076
|
-
findings.push(seen[key]);
|
|
4077
|
-
}
|
|
4078
|
-
}
|
|
4079
|
-
|
|
4080
|
-
function ruleTokenInUrl(requests, findings) {
|
|
4081
|
-
var seen = {};
|
|
4082
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4083
|
-
var r = requests[i];
|
|
4084
|
-
var qIdx = r.url.indexOf('?');
|
|
4085
|
-
if (qIdx === -1) continue;
|
|
4086
|
-
var params = r.url.substring(qIdx + 1).split('&');
|
|
4087
|
-
var flagged = [];
|
|
4088
|
-
for (var pi = 0; pi < params.length; pi++) {
|
|
4089
|
-
var parts = params[pi].split('=');
|
|
4090
|
-
var name = parts[0];
|
|
4091
|
-
var val = parts.slice(1).join('=');
|
|
4092
|
-
if (SAFE_PARAMS.test(name)) continue;
|
|
4093
|
-
if (TOKEN_PARAMS.test(name) && val && val.length > 0) {
|
|
4094
|
-
flagged.push(name);
|
|
4095
|
-
}
|
|
4096
|
-
}
|
|
4097
|
-
if (flagged.length === 0) continue;
|
|
4098
|
-
var ep = r.method + ' ' + r.path;
|
|
4099
|
-
var key = ep + ':' + flagged.sort().join(',');
|
|
4100
|
-
if (seen[key]) { seen[key].count++; continue; }
|
|
4101
|
-
seen[key] = {
|
|
4102
|
-
severity: 'critical', type: 'security', rule: 'token-in-url',
|
|
4103
|
-
title: 'Auth Token in URL',
|
|
4104
|
-
desc: '<strong>' + escHtml(ep) + '</strong> \u2014 <strong>' + escHtml(flagged.join(', ')) + '</strong> exposed in query string',
|
|
4105
|
-
nav: 'security', hint: RULE_HINTS['token-in-url'], endpoint: ep, count: 1
|
|
4106
|
-
};
|
|
4107
|
-
findings.push(seen[key]);
|
|
4108
|
-
}
|
|
4109
|
-
}
|
|
4110
|
-
|
|
4111
|
-
function ruleStackTraceLeak(requests, findings) {
|
|
4112
|
-
var seen = {};
|
|
4113
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4114
|
-
var r = requests[i];
|
|
4115
|
-
if (!r.responseBody) continue;
|
|
4116
|
-
if (!STACK_TRACE_RE.test(r.responseBody)) continue;
|
|
4117
|
-
var ep = r.method + ' ' + r.path;
|
|
4118
|
-
if (seen[ep]) { seen[ep].count++; continue; }
|
|
4119
|
-
seen[ep] = {
|
|
4120
|
-
severity: 'critical', type: 'security', rule: 'stack-trace-leak',
|
|
4121
|
-
title: 'Stack Trace Leaked to Client',
|
|
4122
|
-
desc: '<strong>' + escHtml(ep) + '</strong> \u2014 response exposes internal stack trace',
|
|
4123
|
-
nav: 'security', hint: RULE_HINTS['stack-trace-leak'], endpoint: ep, count: 1
|
|
4124
|
-
};
|
|
4125
|
-
findings.push(seen[ep]);
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
|
|
4129
|
-
function ruleErrorInfoLeak(requests, findings) {
|
|
4130
|
-
var seen = {};
|
|
4131
|
-
var criticalPatterns = [
|
|
4132
|
-
{ re: DB_CONN_RE, label: 'database connection string' },
|
|
4133
|
-
{ re: SQL_FRAGMENT_RE, label: 'SQL query fragment' },
|
|
4134
|
-
{ re: SECRET_VAL_RE, label: 'secret value' }
|
|
4135
|
-
];
|
|
4136
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4137
|
-
var r = requests[i];
|
|
4138
|
-
if (r.statusCode < 400) continue;
|
|
4139
|
-
if (!r.responseBody) continue;
|
|
4140
|
-
if (r.responseHeaders && (r.responseHeaders['x-nextjs-error'] || r.responseHeaders['x-nextjs-matched-path'])) continue;
|
|
4141
|
-
var ep = r.method + ' ' + r.path;
|
|
4142
|
-
for (var pi = 0; pi < criticalPatterns.length; pi++) {
|
|
4143
|
-
var p = criticalPatterns[pi];
|
|
4144
|
-
if (!p.re.test(r.responseBody)) continue;
|
|
4145
|
-
var key = ep + ':' + p.label;
|
|
4146
|
-
if (seen[key]) { seen[key].count++; continue; }
|
|
4147
|
-
seen[key] = {
|
|
4148
|
-
severity: 'critical', type: 'security', rule: 'error-info-leak',
|
|
4149
|
-
title: 'Sensitive Data in Error Response',
|
|
4150
|
-
desc: '<strong>' + escHtml(ep) + '</strong> \u2014 error response exposes <strong>' + p.label + '</strong>',
|
|
4151
|
-
nav: 'security', hint: RULE_HINTS['error-info-leak'], endpoint: ep, count: 1
|
|
4152
|
-
};
|
|
4153
|
-
findings.push(seen[key]);
|
|
4154
|
-
}
|
|
4155
|
-
}
|
|
4156
|
-
}
|
|
4157
|
-
|
|
4158
|
-
function ruleInsecureCookie(requests, findings) {
|
|
4159
|
-
var seen = {};
|
|
4160
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4161
|
-
var r = requests[i];
|
|
4162
|
-
if (!r.responseHeaders) continue;
|
|
4163
|
-
var setCookie = r.responseHeaders['set-cookie'];
|
|
4164
|
-
if (!setCookie) continue;
|
|
4165
|
-
var cookies = setCookie.split(/,(?=\\s*[A-Za-z0-9_\\-]+=)/);
|
|
4166
|
-
for (var ci = 0; ci < cookies.length; ci++) {
|
|
4167
|
-
var cookie = cookies[ci].trim();
|
|
4168
|
-
var cookieName = cookie.split('=')[0].trim();
|
|
4169
|
-
var lower = cookie.toLowerCase();
|
|
4170
|
-
var issues = [];
|
|
4171
|
-
if (lower.indexOf('httponly') === -1) issues.push('HttpOnly');
|
|
4172
|
-
if (lower.indexOf('samesite') === -1) issues.push('SameSite');
|
|
4173
|
-
if (issues.length === 0) continue;
|
|
4174
|
-
var key = cookieName + ':' + issues.join(',');
|
|
4175
|
-
if (seen[key]) { seen[key].count++; continue; }
|
|
4176
|
-
seen[key] = {
|
|
4177
|
-
severity: 'warning', type: 'security', rule: 'insecure-cookie',
|
|
4178
|
-
title: 'Insecure Cookie',
|
|
4179
|
-
desc: '<strong>' + escHtml(cookieName) + '</strong> \u2014 missing <strong>' + issues.join(', ') + '</strong> flag' + (issues.length > 1 ? 's' : ''),
|
|
4180
|
-
nav: 'security', hint: RULE_HINTS['insecure-cookie'], endpoint: cookieName, count: 1
|
|
4181
|
-
};
|
|
4182
|
-
findings.push(seen[key]);
|
|
4183
|
-
}
|
|
4184
|
-
}
|
|
4185
|
-
}
|
|
4186
|
-
`;
|
|
4187
|
-
}
|
|
4188
|
-
|
|
4189
|
-
// src/dashboard/client/rules/warnings.ts
|
|
4190
|
-
function getWarningRules() {
|
|
4191
|
-
return `
|
|
4192
|
-
function ruleSensitiveLogs(findings) {
|
|
4193
|
-
var count = 0;
|
|
4194
|
-
for (var i = 0; i < state.logs.length; i++) {
|
|
4195
|
-
var msg = state.logs[i].message;
|
|
4196
|
-
if (!msg) continue;
|
|
4197
|
-
if (msg.indexOf('[brakit]') === 0) continue;
|
|
4198
|
-
if (LOG_SECRET_RE.test(msg)) count++;
|
|
4199
|
-
}
|
|
4200
|
-
if (count > 0) {
|
|
4201
|
-
findings.push({
|
|
4202
|
-
severity: 'warning', type: 'security', rule: 'sensitive-logs',
|
|
4203
|
-
title: 'Sensitive Data in Logs',
|
|
4204
|
-
desc: 'Console output contains <strong>secret/token values</strong> \u2014 ' + count + ' occurrence' + (count !== 1 ? 's' : ''),
|
|
4205
|
-
nav: 'security', hint: RULE_HINTS['sensitive-logs'], endpoint: 'console', count: count
|
|
4206
|
-
});
|
|
4207
|
-
}
|
|
4208
|
-
}
|
|
4209
|
-
|
|
4210
|
-
function ruleCorsCredentials(requests, findings) {
|
|
4211
|
-
var seen = {};
|
|
4212
|
-
for (var i = 0; i < requests.length; i++) {
|
|
4213
|
-
var r = requests[i];
|
|
4214
|
-
if (!r.responseHeaders) continue;
|
|
4215
|
-
var origin = r.responseHeaders['access-control-allow-origin'];
|
|
4216
|
-
var creds = r.responseHeaders['access-control-allow-credentials'];
|
|
4217
|
-
if (origin !== '*' || creds !== 'true') continue;
|
|
4218
|
-
var ep = r.method + ' ' + r.path;
|
|
4219
|
-
if (seen[ep]) continue;
|
|
4220
|
-
seen[ep] = true;
|
|
4221
|
-
findings.push({
|
|
4222
|
-
severity: 'warning', type: 'security', rule: 'cors-credentials',
|
|
4223
|
-
title: 'CORS Credentials with Wildcard',
|
|
4224
|
-
desc: '<strong>' + escHtml(ep) + '</strong> \u2014 credentials:true with origin:* (browser will reject)',
|
|
4225
|
-
nav: 'security', hint: RULE_HINTS['cors-credentials'], endpoint: ep, count: 1
|
|
4226
|
-
});
|
|
4227
|
-
}
|
|
4228
|
-
}
|
|
4229
|
-
`;
|
|
4230
|
-
}
|
|
4231
|
-
|
|
4232
|
-
// src/dashboard/client/rules/engine.ts
|
|
4233
|
-
function getSecurityEngine() {
|
|
4234
|
-
return `
|
|
4235
|
-
function computeSecurityFindings() {
|
|
4236
|
-
var findings = [];
|
|
4237
|
-
var nonDashboard = state.requests.filter(function(r) {
|
|
4238
|
-
return !r.isStatic && (!r.path || r.path.indexOf('${DASHBOARD_PREFIX}') !== 0);
|
|
4239
|
-
});
|
|
4240
|
-
|
|
4241
|
-
ruleExposedSecret(nonDashboard, findings);
|
|
4242
|
-
ruleTokenInUrl(nonDashboard, findings);
|
|
4243
|
-
ruleStackTraceLeak(nonDashboard, findings);
|
|
4244
|
-
ruleErrorInfoLeak(nonDashboard, findings);
|
|
4245
|
-
ruleSensitiveLogs(findings);
|
|
4246
|
-
ruleCorsCredentials(nonDashboard, findings);
|
|
4247
|
-
ruleInsecureCookie(nonDashboard, findings);
|
|
4248
|
-
|
|
4249
|
-
return findings;
|
|
4250
|
-
}
|
|
4251
|
-
`;
|
|
4252
|
-
}
|
|
4253
|
-
|
|
4254
|
-
// src/dashboard/client/rules/index.ts
|
|
4255
|
-
function getSecurityRules() {
|
|
4256
|
-
return getSecurityPatterns() + getSecurityHelpers() + getCriticalRules() + getWarningRules() + getSecurityEngine();
|
|
3577
|
+
return getOverviewRender();
|
|
4257
3578
|
}
|
|
4258
3579
|
|
|
4259
3580
|
// src/dashboard/client/views/security.ts
|
|
@@ -4264,7 +3585,7 @@ function getSecurityView() {
|
|
|
4264
3585
|
if (!container) return;
|
|
4265
3586
|
container.innerHTML = '';
|
|
4266
3587
|
|
|
4267
|
-
var findings =
|
|
3588
|
+
var findings = state.findings || [];
|
|
4268
3589
|
|
|
4269
3590
|
if (findings.length === 0) {
|
|
4270
3591
|
var hasData = state.requests.length > 0 || state.logs.length > 0 || state.queries.length > 0;
|
|
@@ -4379,6 +3700,18 @@ function getApp() {
|
|
|
4379
3700
|
|
|
4380
3701
|
await Promise.all([loadFetches(), loadErrors(), loadLogs(), loadQueries(), loadMetrics()]);
|
|
4381
3702
|
|
|
3703
|
+
try {
|
|
3704
|
+
var res3 = await fetch('${DASHBOARD_API_INSIGHTS}');
|
|
3705
|
+
var data3 = await res3.json();
|
|
3706
|
+
state.insights = data3.insights || [];
|
|
3707
|
+
} catch(e) { console.warn('[brakit]', e); }
|
|
3708
|
+
|
|
3709
|
+
try {
|
|
3710
|
+
var res4 = await fetch('${DASHBOARD_API_SECURITY}');
|
|
3711
|
+
var data4 = await res4.json();
|
|
3712
|
+
state.findings = data4.findings || [];
|
|
3713
|
+
} catch(e) { console.warn('[brakit]', e); }
|
|
3714
|
+
|
|
4382
3715
|
updateStats();
|
|
4383
3716
|
renderOverview();
|
|
4384
3717
|
|
|
@@ -4435,6 +3768,18 @@ function getApp() {
|
|
|
4435
3768
|
updateStats();
|
|
4436
3769
|
if (q.parentRequestId) { invalidateTimelineCache(q.parentRequestId); refreshVisibleTimeline(q.parentRequestId); }
|
|
4437
3770
|
});
|
|
3771
|
+
|
|
3772
|
+
events.addEventListener('insights', function(e) {
|
|
3773
|
+
state.insights = JSON.parse(e.data);
|
|
3774
|
+
if (state.activeView === 'overview') renderOverview();
|
|
3775
|
+
updateStats();
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
events.addEventListener('security', function(e) {
|
|
3779
|
+
state.findings = JSON.parse(e.data);
|
|
3780
|
+
if (state.activeView === 'security') renderSecurity();
|
|
3781
|
+
updateStats();
|
|
3782
|
+
});
|
|
4438
3783
|
}
|
|
4439
3784
|
|
|
4440
3785
|
async function reloadFlows() {
|
|
@@ -4444,7 +3789,6 @@ function getApp() {
|
|
|
4444
3789
|
state.flows = data.flows;
|
|
4445
3790
|
renderFlows();
|
|
4446
3791
|
updateStats();
|
|
4447
|
-
renderOverview();
|
|
4448
3792
|
} catch(e) { console.warn('[brakit]', e); }
|
|
4449
3793
|
}
|
|
4450
3794
|
|
|
@@ -4463,6 +3807,7 @@ function getApp() {
|
|
|
4463
3807
|
sidebarItems.forEach(function(i) { i.classList.remove('active'); });
|
|
4464
3808
|
item.classList.add('active');
|
|
4465
3809
|
state.activeView = view;
|
|
3810
|
+
fetch('${DASHBOARD_API_TAB}?tab=' + encodeURIComponent(view)).catch(function(){});
|
|
4466
3811
|
document.getElementById('header-title').textContent = VIEW_TITLES[view] || view;
|
|
4467
3812
|
document.getElementById('header-sub').textContent = VIEW_SUBTITLES[view] || '';
|
|
4468
3813
|
document.getElementById('mode-toggle').style.display = view === 'actions' ? 'flex' : 'none';
|
|
@@ -4508,9 +3853,9 @@ function getApp() {
|
|
|
4508
3853
|
if (queryCount) queryCount.textContent = state.queries.length;
|
|
4509
3854
|
var secCount = document.getElementById('sidebar-count-security');
|
|
4510
3855
|
if (secCount) {
|
|
4511
|
-
var
|
|
4512
|
-
secCount.textContent =
|
|
4513
|
-
secCount.style.display =
|
|
3856
|
+
var numFindings = (state.findings || []).length;
|
|
3857
|
+
secCount.textContent = numFindings;
|
|
3858
|
+
secCount.style.display = numFindings > 0 ? '' : 'none';
|
|
4514
3859
|
}
|
|
4515
3860
|
}
|
|
4516
3861
|
|
|
@@ -4528,6 +3873,7 @@ function getApp() {
|
|
|
4528
3873
|
if (!confirm('This will clear all data including performance metrics history. Continue?')) return;
|
|
4529
3874
|
await fetch('${DASHBOARD_API_CLEAR}', {method: 'POST'});
|
|
4530
3875
|
state.flows = []; state.requests = []; state.fetches = []; state.errors = []; state.logs = []; state.queries = [];
|
|
3876
|
+
state.insights = []; state.findings = [];
|
|
4531
3877
|
graphData = []; selectedEndpoint = ${ALL_ENDPOINTS_SELECTOR}; timelineCache = {};
|
|
4532
3878
|
renderFlows(); renderRequests(); renderFetches(); renderErrors(); renderLogs(); renderQueries(); renderGraph(); renderOverview(); renderSecurity(); updateStats();
|
|
4533
3879
|
showToast('Cleared');
|
|
@@ -4542,7 +3888,7 @@ function getClientScript(config) {
|
|
|
4542
3888
|
return `
|
|
4543
3889
|
(function(){
|
|
4544
3890
|
var PORT = ${config.proxyPort};
|
|
4545
|
-
var state = { flows: [], requests: [], fetches: [], errors: [], logs: [], queries: [], viewMode: 'simple', activeView: 'overview' };
|
|
3891
|
+
var state = { flows: [], requests: [], fetches: [], errors: [], logs: [], queries: [], insights: [], findings: [], viewMode: 'simple', activeView: 'overview' };
|
|
4546
3892
|
|
|
4547
3893
|
var appEl = document.getElementById('app');
|
|
4548
3894
|
var flowListEl = document.getElementById('flow-list');
|
|
@@ -4561,7 +3907,6 @@ function getClientScript(config) {
|
|
|
4561
3907
|
${getQueriesView()}
|
|
4562
3908
|
${getTimelineView()}
|
|
4563
3909
|
${getGraphView()}
|
|
4564
|
-
${getSecurityRules()}
|
|
4565
3910
|
${getOverviewView()}
|
|
4566
3911
|
${getSecurityView()}
|
|
4567
3912
|
${getApp()}
|
|
@@ -4586,6 +3931,155 @@ ${getLayoutHtml(config)}
|
|
|
4586
3931
|
</html>`;
|
|
4587
3932
|
}
|
|
4588
3933
|
|
|
3934
|
+
// src/telemetry/index.ts
|
|
3935
|
+
import { platform, release, arch } from "os";
|
|
3936
|
+
|
|
3937
|
+
// src/telemetry/config.ts
|
|
3938
|
+
import { homedir } from "os";
|
|
3939
|
+
import { join } from "path";
|
|
3940
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
|
|
3941
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
3942
|
+
var CONFIG_DIR = join(homedir(), ".brakit");
|
|
3943
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
3944
|
+
function readConfig() {
|
|
3945
|
+
try {
|
|
3946
|
+
if (!existsSync3(CONFIG_PATH)) return null;
|
|
3947
|
+
return JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
|
|
3948
|
+
} catch {
|
|
3949
|
+
return null;
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
function writeConfig(config) {
|
|
3953
|
+
try {
|
|
3954
|
+
if (!existsSync3(CONFIG_DIR)) mkdirSync3(CONFIG_DIR, { recursive: true });
|
|
3955
|
+
writeFileSync3(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
3956
|
+
} catch {
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
function getOrCreateConfig() {
|
|
3960
|
+
const existing = readConfig();
|
|
3961
|
+
if (existing && typeof existing.telemetry === "boolean" && existing.anonymousId) {
|
|
3962
|
+
return existing;
|
|
3963
|
+
}
|
|
3964
|
+
const config = { telemetry: true, anonymousId: randomUUID5() };
|
|
3965
|
+
writeConfig(config);
|
|
3966
|
+
return config;
|
|
3967
|
+
}
|
|
3968
|
+
function isTelemetryEnabled() {
|
|
3969
|
+
const env = process.env.BRAKIT_TELEMETRY;
|
|
3970
|
+
if (env !== void 0) return env !== "false" && env !== "0" && env !== "off";
|
|
3971
|
+
return readConfig()?.telemetry ?? true;
|
|
3972
|
+
}
|
|
3973
|
+
function setTelemetryEnabled(enabled) {
|
|
3974
|
+
const config = getOrCreateConfig();
|
|
3975
|
+
config.telemetry = enabled;
|
|
3976
|
+
writeConfig(config);
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
// src/telemetry/index.ts
|
|
3980
|
+
var POSTHOG_HOST = "https://app.posthog.com";
|
|
3981
|
+
var POSTHOG_KEY = "phc_gH8aQFZ2Fn8db9LEdgomOvymLiP6mm6FPTYXffQceR8";
|
|
3982
|
+
var startTime = 0;
|
|
3983
|
+
var sessionFramework = "";
|
|
3984
|
+
var sessionPackageManager = "";
|
|
3985
|
+
var sessionIsCustomCommand = false;
|
|
3986
|
+
var sessionAdapters = [];
|
|
3987
|
+
var requestCount = 0;
|
|
3988
|
+
var insightTypes = /* @__PURE__ */ new Set();
|
|
3989
|
+
var rulesTriggered = /* @__PURE__ */ new Set();
|
|
3990
|
+
var tabsViewed = /* @__PURE__ */ new Set();
|
|
3991
|
+
var dashboardOpened = false;
|
|
3992
|
+
var explainUsed = false;
|
|
3993
|
+
function initSession(framework, packageManager, isCustomCommand, adapters) {
|
|
3994
|
+
startTime = Date.now();
|
|
3995
|
+
sessionFramework = framework;
|
|
3996
|
+
sessionPackageManager = packageManager;
|
|
3997
|
+
sessionIsCustomCommand = isCustomCommand;
|
|
3998
|
+
sessionAdapters = adapters;
|
|
3999
|
+
}
|
|
4000
|
+
function recordRequestCount(count) {
|
|
4001
|
+
requestCount = count;
|
|
4002
|
+
}
|
|
4003
|
+
function recordInsightTypes(types) {
|
|
4004
|
+
for (const t of types) insightTypes.add(t);
|
|
4005
|
+
}
|
|
4006
|
+
function recordRulesTriggered(rules) {
|
|
4007
|
+
for (const r of rules) rulesTriggered.add(r);
|
|
4008
|
+
}
|
|
4009
|
+
function recordTabViewed(tab) {
|
|
4010
|
+
tabsViewed.add(tab);
|
|
4011
|
+
}
|
|
4012
|
+
function recordDashboardOpened() {
|
|
4013
|
+
dashboardOpened = true;
|
|
4014
|
+
}
|
|
4015
|
+
function speedBucket(ms) {
|
|
4016
|
+
if (ms === 0) return "none";
|
|
4017
|
+
if (ms < 200) return "<200ms";
|
|
4018
|
+
if (ms < 500) return "200-500ms";
|
|
4019
|
+
if (ms < 1e3) return "500-1000ms";
|
|
4020
|
+
if (ms < 2e3) return "1000-2000ms";
|
|
4021
|
+
if (ms < 5e3) return "2000-5000ms";
|
|
4022
|
+
return ">5000ms";
|
|
4023
|
+
}
|
|
4024
|
+
function trackSession(metricsStore, analysisEngine) {
|
|
4025
|
+
if (!isTelemetryEnabled()) return;
|
|
4026
|
+
const isFirstSession = readConfig() === null;
|
|
4027
|
+
const config = getOrCreateConfig();
|
|
4028
|
+
const live = metricsStore.getLiveEndpoints();
|
|
4029
|
+
const insights = analysisEngine.getInsights();
|
|
4030
|
+
const findings = analysisEngine.getFindings();
|
|
4031
|
+
let totalRequests = 0;
|
|
4032
|
+
let totalDuration = 0;
|
|
4033
|
+
let slowestP95 = 0;
|
|
4034
|
+
for (const ep of live) {
|
|
4035
|
+
totalRequests += ep.summary.totalRequests;
|
|
4036
|
+
totalDuration += ep.summary.p95Ms * ep.summary.totalRequests;
|
|
4037
|
+
if (ep.summary.p95Ms > slowestP95) slowestP95 = ep.summary.p95Ms;
|
|
4038
|
+
}
|
|
4039
|
+
const payload = {
|
|
4040
|
+
api_key: POSTHOG_KEY,
|
|
4041
|
+
event: "session",
|
|
4042
|
+
distinct_id: config.anonymousId,
|
|
4043
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4044
|
+
properties: {
|
|
4045
|
+
brakit_version: VERSION,
|
|
4046
|
+
node_version: process.version,
|
|
4047
|
+
os: `${platform()}-${release()}`,
|
|
4048
|
+
arch: arch(),
|
|
4049
|
+
framework: sessionFramework,
|
|
4050
|
+
package_manager: sessionPackageManager,
|
|
4051
|
+
is_custom_command: sessionIsCustomCommand,
|
|
4052
|
+
first_session: isFirstSession,
|
|
4053
|
+
adapters_detected: sessionAdapters,
|
|
4054
|
+
request_count: requestCount,
|
|
4055
|
+
error_count: defaultErrorStore.getAll().length,
|
|
4056
|
+
query_count: defaultQueryStore.getAll().length,
|
|
4057
|
+
fetch_count: defaultFetchStore.getAll().length,
|
|
4058
|
+
insight_count: insights.length,
|
|
4059
|
+
finding_count: findings.length,
|
|
4060
|
+
insight_types: [...insightTypes],
|
|
4061
|
+
rules_triggered: [...rulesTriggered],
|
|
4062
|
+
endpoint_count: live.length,
|
|
4063
|
+
avg_duration_ms: totalRequests > 0 ? Math.round(totalDuration / totalRequests) : 0,
|
|
4064
|
+
slowest_endpoint_bucket: speedBucket(slowestP95),
|
|
4065
|
+
tabs_viewed: [...tabsViewed],
|
|
4066
|
+
dashboard_opened: dashboardOpened,
|
|
4067
|
+
explain_used: explainUsed,
|
|
4068
|
+
session_duration_s: Math.round((Date.now() - startTime) / 1e3),
|
|
4069
|
+
$lib: "brakit",
|
|
4070
|
+
$ip: null,
|
|
4071
|
+
$geoip_disable: true
|
|
4072
|
+
}
|
|
4073
|
+
};
|
|
4074
|
+
fetch(`${POSTHOG_HOST}/capture`, {
|
|
4075
|
+
method: "POST",
|
|
4076
|
+
headers: { "content-type": "application/json" },
|
|
4077
|
+
body: JSON.stringify(payload),
|
|
4078
|
+
signal: AbortSignal.timeout(5e3)
|
|
4079
|
+
}).catch(() => {
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4589
4083
|
// src/dashboard/router.ts
|
|
4590
4084
|
function isDashboardRequest(url) {
|
|
4591
4085
|
return url === DASHBOARD_PREFIX || url.startsWith(DASHBOARD_PREFIX + "/");
|
|
@@ -4593,7 +4087,7 @@ function isDashboardRequest(url) {
|
|
|
4593
4087
|
function createDashboardHandler(deps) {
|
|
4594
4088
|
const routes = {
|
|
4595
4089
|
[DASHBOARD_API_REQUESTS]: handleApiRequests,
|
|
4596
|
-
[DASHBOARD_API_EVENTS]:
|
|
4090
|
+
[DASHBOARD_API_EVENTS]: createSSEHandler(deps.analysisEngine),
|
|
4597
4091
|
[DASHBOARD_API_FLOWS]: handleApiFlows,
|
|
4598
4092
|
[DASHBOARD_API_CLEAR]: createClearHandler(deps.metricsStore),
|
|
4599
4093
|
[DASHBOARD_API_LOGS]: handleApiLogs,
|
|
@@ -4609,6 +4103,12 @@ function createDashboardHandler(deps) {
|
|
|
4609
4103
|
routes[DASHBOARD_API_INSIGHTS] = createInsightsHandler(deps.analysisEngine);
|
|
4610
4104
|
routes[DASHBOARD_API_SECURITY] = createSecurityHandler(deps.analysisEngine);
|
|
4611
4105
|
}
|
|
4106
|
+
routes[DASHBOARD_API_TAB] = (req, res) => {
|
|
4107
|
+
const tab = (req.url ?? "").split("tab=")[1];
|
|
4108
|
+
if (tab && isTelemetryEnabled()) recordTabViewed(decodeURIComponent(tab));
|
|
4109
|
+
res.writeHead(204);
|
|
4110
|
+
res.end();
|
|
4111
|
+
};
|
|
4612
4112
|
return (req, res, config) => {
|
|
4613
4113
|
const path = (req.url ?? "/").split("?")[0];
|
|
4614
4114
|
const handler = routes[path];
|
|
@@ -4616,6 +4116,7 @@ function createDashboardHandler(deps) {
|
|
|
4616
4116
|
handler(req, res);
|
|
4617
4117
|
return;
|
|
4618
4118
|
}
|
|
4119
|
+
if (isTelemetryEnabled()) recordDashboardOpened();
|
|
4619
4120
|
res.writeHead(200, {
|
|
4620
4121
|
"content-type": "text/html; charset=utf-8",
|
|
4621
4122
|
"cache-control": "no-cache"
|
|
@@ -4641,7 +4142,7 @@ function createProxyServer(config, handleDashboard) {
|
|
|
4641
4142
|
|
|
4642
4143
|
// src/detect/project.ts
|
|
4643
4144
|
import { readFile as readFile2 } from "fs/promises";
|
|
4644
|
-
import { join } from "path";
|
|
4145
|
+
import { join as join2 } from "path";
|
|
4645
4146
|
var FRAMEWORKS = [
|
|
4646
4147
|
{ name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
4647
4148
|
{ name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
|
|
@@ -4650,7 +4151,7 @@ var FRAMEWORKS = [
|
|
|
4650
4151
|
{ name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
|
|
4651
4152
|
];
|
|
4652
4153
|
async function detectProject(rootDir) {
|
|
4653
|
-
const pkgPath =
|
|
4154
|
+
const pkgPath = join2(rootDir, "package.json");
|
|
4654
4155
|
const raw = await readFile2(pkgPath, "utf-8");
|
|
4655
4156
|
const pkg = JSON.parse(raw);
|
|
4656
4157
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
@@ -4662,7 +4163,7 @@ async function detectProject(rootDir) {
|
|
|
4662
4163
|
if (allDeps[f.dep]) {
|
|
4663
4164
|
framework = f.name;
|
|
4664
4165
|
devCommand = f.devCmd;
|
|
4665
|
-
devBin =
|
|
4166
|
+
devBin = join2(rootDir, "node_modules", ".bin", f.bin);
|
|
4666
4167
|
defaultPort = f.defaultPort;
|
|
4667
4168
|
break;
|
|
4668
4169
|
}
|
|
@@ -4671,11 +4172,11 @@ async function detectProject(rootDir) {
|
|
|
4671
4172
|
return { framework, devCommand, devBin, defaultPort, packageManager };
|
|
4672
4173
|
}
|
|
4673
4174
|
async function detectPackageManager(rootDir) {
|
|
4674
|
-
if (await fileExists(
|
|
4675
|
-
if (await fileExists(
|
|
4676
|
-
if (await fileExists(
|
|
4677
|
-
if (await fileExists(
|
|
4678
|
-
if (await fileExists(
|
|
4175
|
+
if (await fileExists(join2(rootDir, "bun.lockb"))) return "bun";
|
|
4176
|
+
if (await fileExists(join2(rootDir, "bun.lock"))) return "bun";
|
|
4177
|
+
if (await fileExists(join2(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
4178
|
+
if (await fileExists(join2(rootDir, "yarn.lock"))) return "yarn";
|
|
4179
|
+
if (await fileExists(join2(rootDir, "package-lock.json"))) return "npm";
|
|
4679
4180
|
return "unknown";
|
|
4680
4181
|
}
|
|
4681
4182
|
|
|
@@ -4689,6 +4190,9 @@ var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+S
|
|
|
4689
4190
|
var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
|
|
4690
4191
|
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
|
|
4691
4192
|
var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
4193
|
+
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
4194
|
+
var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
|
|
4195
|
+
var INTERNAL_ID_SUFFIX = /Id$|_id$/;
|
|
4692
4196
|
var RULE_HINTS = {
|
|
4693
4197
|
"exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
|
|
4694
4198
|
"token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
|
|
@@ -4696,7 +4200,8 @@ var RULE_HINTS = {
|
|
|
4696
4200
|
"error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
|
|
4697
4201
|
"sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
|
|
4698
4202
|
"cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
|
|
4699
|
-
"insecure-cookie": "Set HttpOnly and SameSite flags on all cookies."
|
|
4203
|
+
"insecure-cookie": "Set HttpOnly and SameSite flags on all cookies.",
|
|
4204
|
+
"response-pii-leak": "API responses should return minimal data. Don't echo back full user records \u2014 select only the fields the client needs."
|
|
4700
4205
|
};
|
|
4701
4206
|
|
|
4702
4207
|
// src/analysis/rules/exposed-secret.ts
|
|
@@ -4890,6 +4395,12 @@ var errorInfoLeakRule = {
|
|
|
4890
4395
|
};
|
|
4891
4396
|
|
|
4892
4397
|
// src/analysis/rules/insecure-cookie.ts
|
|
4398
|
+
function isFrameworkResponse(r) {
|
|
4399
|
+
if (r.statusCode >= 300 && r.statusCode < 400) return true;
|
|
4400
|
+
if (r.path?.startsWith("/__")) return true;
|
|
4401
|
+
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
4402
|
+
return false;
|
|
4403
|
+
}
|
|
4893
4404
|
var insecureCookieRule = {
|
|
4894
4405
|
id: "insecure-cookie",
|
|
4895
4406
|
severity: "warning",
|
|
@@ -4900,6 +4411,7 @@ var insecureCookieRule = {
|
|
|
4900
4411
|
const seen = /* @__PURE__ */ new Map();
|
|
4901
4412
|
for (const r of ctx.requests) {
|
|
4902
4413
|
if (!r.responseHeaders) continue;
|
|
4414
|
+
if (isFrameworkResponse(r)) continue;
|
|
4903
4415
|
const setCookie = r.responseHeaders["set-cookie"];
|
|
4904
4416
|
if (!setCookie) continue;
|
|
4905
4417
|
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
@@ -4990,6 +4502,157 @@ var corsCredentialsRule = {
|
|
|
4990
4502
|
}
|
|
4991
4503
|
};
|
|
4992
4504
|
|
|
4505
|
+
// src/analysis/rules/response-pii-leak.ts
|
|
4506
|
+
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
4507
|
+
var FULL_RECORD_MIN_FIELDS = 5;
|
|
4508
|
+
var LIST_PII_MIN_ITEMS = 2;
|
|
4509
|
+
function tryParseJson2(body) {
|
|
4510
|
+
if (!body) return null;
|
|
4511
|
+
try {
|
|
4512
|
+
return JSON.parse(body);
|
|
4513
|
+
} catch {
|
|
4514
|
+
return null;
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
function findEmails(obj) {
|
|
4518
|
+
const emails = [];
|
|
4519
|
+
if (!obj || typeof obj !== "object") return emails;
|
|
4520
|
+
if (Array.isArray(obj)) {
|
|
4521
|
+
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
|
4522
|
+
emails.push(...findEmails(obj[i]));
|
|
4523
|
+
}
|
|
4524
|
+
return emails;
|
|
4525
|
+
}
|
|
4526
|
+
for (const v of Object.values(obj)) {
|
|
4527
|
+
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
4528
|
+
emails.push(v);
|
|
4529
|
+
} else if (typeof v === "object" && v !== null) {
|
|
4530
|
+
emails.push(...findEmails(v));
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
return emails;
|
|
4534
|
+
}
|
|
4535
|
+
function topLevelFieldCount(obj) {
|
|
4536
|
+
if (Array.isArray(obj)) {
|
|
4537
|
+
return obj.length > 0 ? topLevelFieldCount(obj[0]) : 0;
|
|
4538
|
+
}
|
|
4539
|
+
if (obj && typeof obj === "object") return Object.keys(obj).length;
|
|
4540
|
+
return 0;
|
|
4541
|
+
}
|
|
4542
|
+
function hasInternalIds(obj) {
|
|
4543
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false;
|
|
4544
|
+
for (const key of Object.keys(obj)) {
|
|
4545
|
+
if (INTERNAL_ID_KEYS.test(key) || INTERNAL_ID_SUFFIX.test(key)) return true;
|
|
4546
|
+
}
|
|
4547
|
+
return false;
|
|
4548
|
+
}
|
|
4549
|
+
function unwrapResponse(parsed) {
|
|
4550
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
4551
|
+
const obj = parsed;
|
|
4552
|
+
const keys = Object.keys(obj);
|
|
4553
|
+
if (keys.length > 3) return parsed;
|
|
4554
|
+
let best = null;
|
|
4555
|
+
let bestSize = 0;
|
|
4556
|
+
for (const key of keys) {
|
|
4557
|
+
const val = obj[key];
|
|
4558
|
+
if (Array.isArray(val) && val.length > bestSize) {
|
|
4559
|
+
best = val;
|
|
4560
|
+
bestSize = val.length;
|
|
4561
|
+
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
4562
|
+
const size = Object.keys(val).length;
|
|
4563
|
+
if (size > bestSize) {
|
|
4564
|
+
best = val;
|
|
4565
|
+
bestSize = size;
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
return best && bestSize >= 3 ? best : parsed;
|
|
4570
|
+
}
|
|
4571
|
+
function detectPII(method, reqBody, resBody) {
|
|
4572
|
+
const target = unwrapResponse(resBody);
|
|
4573
|
+
if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
|
|
4574
|
+
const reqEmails = findEmails(reqBody);
|
|
4575
|
+
if (reqEmails.length > 0) {
|
|
4576
|
+
const resEmails = findEmails(target);
|
|
4577
|
+
const echoed = reqEmails.filter((e) => resEmails.includes(e));
|
|
4578
|
+
if (echoed.length > 0) {
|
|
4579
|
+
const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
4580
|
+
if (hasInternalIds(inspectObj) || topLevelFieldCount(inspectObj) >= FULL_RECORD_MIN_FIELDS) {
|
|
4581
|
+
return { reason: "echo", emailCount: echoed.length };
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
4587
|
+
const fields = topLevelFieldCount(target);
|
|
4588
|
+
if (fields >= FULL_RECORD_MIN_FIELDS && hasInternalIds(target)) {
|
|
4589
|
+
const emails = findEmails(target);
|
|
4590
|
+
if (emails.length > 0) {
|
|
4591
|
+
return { reason: "full-record", emailCount: emails.length };
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
}
|
|
4595
|
+
if (Array.isArray(target) && target.length >= LIST_PII_MIN_ITEMS) {
|
|
4596
|
+
let itemsWithEmail = 0;
|
|
4597
|
+
for (let i = 0; i < Math.min(target.length, 10); i++) {
|
|
4598
|
+
const item = target[i];
|
|
4599
|
+
if (item && typeof item === "object") {
|
|
4600
|
+
const emails = findEmails(item);
|
|
4601
|
+
if (emails.length > 0) itemsWithEmail++;
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
if (itemsWithEmail >= LIST_PII_MIN_ITEMS) {
|
|
4605
|
+
const first = target[0];
|
|
4606
|
+
if (hasInternalIds(first) || topLevelFieldCount(first) >= FULL_RECORD_MIN_FIELDS) {
|
|
4607
|
+
return { reason: "list-pii", emailCount: itemsWithEmail };
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
return null;
|
|
4612
|
+
}
|
|
4613
|
+
var REASON_LABELS = {
|
|
4614
|
+
echo: "echoes back PII from the request body",
|
|
4615
|
+
"full-record": "returns a full record with email and internal IDs",
|
|
4616
|
+
"list-pii": "returns a list of records containing email addresses"
|
|
4617
|
+
};
|
|
4618
|
+
var responsePiiLeakRule = {
|
|
4619
|
+
id: "response-pii-leak",
|
|
4620
|
+
severity: "warning",
|
|
4621
|
+
name: "PII Leak in Response",
|
|
4622
|
+
hint: RULE_HINTS["response-pii-leak"],
|
|
4623
|
+
check(ctx) {
|
|
4624
|
+
const findings = [];
|
|
4625
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4626
|
+
for (const r of ctx.requests) {
|
|
4627
|
+
if (r.statusCode >= 400) continue;
|
|
4628
|
+
const resJson = tryParseJson2(r.responseBody);
|
|
4629
|
+
if (!resJson) continue;
|
|
4630
|
+
const reqJson = tryParseJson2(r.requestBody);
|
|
4631
|
+
const detection = detectPII(r.method, reqJson, resJson);
|
|
4632
|
+
if (!detection) continue;
|
|
4633
|
+
const ep = `${r.method} ${r.path}`;
|
|
4634
|
+
const dedupKey = `${ep}:${detection.reason}`;
|
|
4635
|
+
const existing = seen.get(dedupKey);
|
|
4636
|
+
if (existing) {
|
|
4637
|
+
existing.count++;
|
|
4638
|
+
continue;
|
|
4639
|
+
}
|
|
4640
|
+
const finding = {
|
|
4641
|
+
severity: "warning",
|
|
4642
|
+
rule: "response-pii-leak",
|
|
4643
|
+
title: "PII Leak in Response",
|
|
4644
|
+
desc: `${ep} \u2014 ${REASON_LABELS[detection.reason]}`,
|
|
4645
|
+
hint: this.hint,
|
|
4646
|
+
endpoint: ep,
|
|
4647
|
+
count: 1
|
|
4648
|
+
};
|
|
4649
|
+
seen.set(dedupKey, finding);
|
|
4650
|
+
findings.push(finding);
|
|
4651
|
+
}
|
|
4652
|
+
return findings;
|
|
4653
|
+
}
|
|
4654
|
+
};
|
|
4655
|
+
|
|
4993
4656
|
// src/analysis/rules/scanner.ts
|
|
4994
4657
|
var SecurityScanner = class {
|
|
4995
4658
|
rules = [];
|
|
@@ -5019,6 +4682,7 @@ function createDefaultScanner() {
|
|
|
5019
4682
|
scanner.register(insecureCookieRule);
|
|
5020
4683
|
scanner.register(sensitiveLogsRule);
|
|
5021
4684
|
scanner.register(corsCredentialsRule);
|
|
4685
|
+
scanner.register(responsePiiLeakRule);
|
|
5022
4686
|
return scanner;
|
|
5023
4687
|
}
|
|
5024
4688
|
|
|
@@ -5055,7 +4719,6 @@ function normalizeQueryParams(sql) {
|
|
|
5055
4719
|
}
|
|
5056
4720
|
|
|
5057
4721
|
// src/analysis/insights.ts
|
|
5058
|
-
var AUTH_CATEGORIES = /* @__PURE__ */ new Set(["auth-handshake", "auth-check", "middleware"]);
|
|
5059
4722
|
function getQueryShape(q) {
|
|
5060
4723
|
if (q.sql) return normalizeQueryParams(q.sql) ?? "";
|
|
5061
4724
|
return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
|
|
@@ -5297,29 +4960,6 @@ function computeInsights(ctx) {
|
|
|
5297
4960
|
});
|
|
5298
4961
|
}
|
|
5299
4962
|
}
|
|
5300
|
-
for (const flow of ctx.flows) {
|
|
5301
|
-
if (!flow.requests || flow.requests.length < 2) continue;
|
|
5302
|
-
let authMs = 0;
|
|
5303
|
-
let totalMs = 0;
|
|
5304
|
-
for (const r of flow.requests) {
|
|
5305
|
-
const dur = r.pollingDurationMs ?? r.durationMs;
|
|
5306
|
-
totalMs += dur;
|
|
5307
|
-
if (AUTH_CATEGORIES.has(r.category ?? "")) authMs += dur;
|
|
5308
|
-
}
|
|
5309
|
-
if (totalMs > 0 && authMs > 0) {
|
|
5310
|
-
const pct = Math.round(authMs / totalMs * 100);
|
|
5311
|
-
if (pct >= AUTH_OVERHEAD_PCT) {
|
|
5312
|
-
insights.push({
|
|
5313
|
-
severity: "warning",
|
|
5314
|
-
type: "auth-overhead",
|
|
5315
|
-
title: "Auth Overhead",
|
|
5316
|
-
desc: `${flow.label} \u2014 ${pct}% of time (${formatDuration(authMs)}) spent in auth/middleware`,
|
|
5317
|
-
hint: "Auth checks consume a significant portion of this action. If using a third-party auth provider, check if session caching can reduce roundtrips.",
|
|
5318
|
-
nav: "actions"
|
|
5319
|
-
});
|
|
5320
|
-
}
|
|
5321
|
-
}
|
|
5322
|
-
}
|
|
5323
4963
|
const selectStarSeen = /* @__PURE__ */ new Map();
|
|
5324
4964
|
for (const [, reqQueries] of queriesByReq) {
|
|
5325
4965
|
for (const q of reqQueries) {
|
|
@@ -5368,6 +5008,69 @@ function computeInsights(ctx) {
|
|
|
5368
5008
|
nav: "queries"
|
|
5369
5009
|
});
|
|
5370
5010
|
}
|
|
5011
|
+
const overfetchSeen = /* @__PURE__ */ new Set();
|
|
5012
|
+
for (const r of nonStatic) {
|
|
5013
|
+
if (r.statusCode >= 400 || !r.responseBody) continue;
|
|
5014
|
+
const ep = `${r.method} ${r.path}`;
|
|
5015
|
+
if (overfetchSeen.has(ep)) continue;
|
|
5016
|
+
let parsed;
|
|
5017
|
+
try {
|
|
5018
|
+
parsed = JSON.parse(r.responseBody);
|
|
5019
|
+
} catch {
|
|
5020
|
+
continue;
|
|
5021
|
+
}
|
|
5022
|
+
let target = parsed;
|
|
5023
|
+
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
5024
|
+
const topKeys = Object.keys(target);
|
|
5025
|
+
if (topKeys.length <= 3) {
|
|
5026
|
+
let best = null;
|
|
5027
|
+
let bestSize = 0;
|
|
5028
|
+
for (const k of topKeys) {
|
|
5029
|
+
const val = target[k];
|
|
5030
|
+
if (Array.isArray(val) && val.length > bestSize) {
|
|
5031
|
+
best = val;
|
|
5032
|
+
bestSize = val.length;
|
|
5033
|
+
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
5034
|
+
const size = Object.keys(val).length;
|
|
5035
|
+
if (size > bestSize) {
|
|
5036
|
+
best = val;
|
|
5037
|
+
bestSize = size;
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
if (best && bestSize >= 3) target = best;
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
5045
|
+
if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
|
|
5046
|
+
const fields = Object.keys(inspectObj);
|
|
5047
|
+
if (fields.length < OVERFETCH_MIN_FIELDS) continue;
|
|
5048
|
+
let internalIdCount = 0;
|
|
5049
|
+
let nullCount = 0;
|
|
5050
|
+
for (const key of fields) {
|
|
5051
|
+
if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
|
|
5052
|
+
const val = inspectObj[key];
|
|
5053
|
+
if (val === null || val === void 0) nullCount++;
|
|
5054
|
+
}
|
|
5055
|
+
const nullRatio = nullCount / fields.length;
|
|
5056
|
+
const reasons = [];
|
|
5057
|
+
if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
|
|
5058
|
+
if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
|
|
5059
|
+
if (fields.length >= OVERFETCH_MIN_FIELDS && reasons.length === 0 && fields.length >= 12) {
|
|
5060
|
+
reasons.push(`${fields.length} fields returned`);
|
|
5061
|
+
}
|
|
5062
|
+
if (reasons.length > 0) {
|
|
5063
|
+
overfetchSeen.add(ep);
|
|
5064
|
+
insights.push({
|
|
5065
|
+
severity: "info",
|
|
5066
|
+
type: "response-overfetch",
|
|
5067
|
+
title: "Response Overfetch",
|
|
5068
|
+
desc: `${ep} \u2014 ${reasons.join(", ")}`,
|
|
5069
|
+
hint: "This response returns more data than the client likely needs. Use a DTO or select only required fields to reduce payload size and avoid leaking internal structure.",
|
|
5070
|
+
nav: "requests"
|
|
5071
|
+
});
|
|
5072
|
+
}
|
|
5073
|
+
}
|
|
5371
5074
|
for (const [ep, g] of endpointGroups) {
|
|
5372
5075
|
if (g.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
5373
5076
|
const avgSize = Math.round(g.totalSize / g.total);
|
|
@@ -5478,7 +5181,7 @@ var AnalysisEngine = class {
|
|
|
5478
5181
|
};
|
|
5479
5182
|
|
|
5480
5183
|
// src/index.ts
|
|
5481
|
-
var VERSION = "0.6.
|
|
5184
|
+
var VERSION = "0.6.2";
|
|
5482
5185
|
|
|
5483
5186
|
// src/lifecycle/startup.ts
|
|
5484
5187
|
import pc2 from "picocolors";
|
|
@@ -5500,6 +5203,53 @@ function printBanner(proxyPort, targetPort) {
|
|
|
5500
5203
|
);
|
|
5501
5204
|
console.log();
|
|
5502
5205
|
}
|
|
5206
|
+
function severityIcon(severity) {
|
|
5207
|
+
if (severity === "critical") return pc.red("\u2717");
|
|
5208
|
+
if (severity === "warning") return pc.yellow("\u26A0");
|
|
5209
|
+
return pc.dim("\u25CB");
|
|
5210
|
+
}
|
|
5211
|
+
function colorTitle(severity, text) {
|
|
5212
|
+
if (severity === "critical") return pc.red(pc.bold(text));
|
|
5213
|
+
if (severity === "warning") return pc.yellow(pc.bold(text));
|
|
5214
|
+
return pc.dim(text);
|
|
5215
|
+
}
|
|
5216
|
+
function truncate(s, max = 80) {
|
|
5217
|
+
return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
|
|
5218
|
+
}
|
|
5219
|
+
function formatConsoleLine(insight, dashboardUrl, suffix) {
|
|
5220
|
+
const icon = severityIcon(insight.severity);
|
|
5221
|
+
const title = colorTitle(insight.severity, insight.title);
|
|
5222
|
+
const desc = pc.dim(truncate(insight.desc) + (suffix ?? ""));
|
|
5223
|
+
const link = pc.dim(`\u2192 ${dashboardUrl}`);
|
|
5224
|
+
return ` ${icon} ${title} \u2014 ${desc} ${link}`;
|
|
5225
|
+
}
|
|
5226
|
+
function createConsoleInsightListener(proxyPort, metricsStore) {
|
|
5227
|
+
const printedKeys = /* @__PURE__ */ new Set();
|
|
5228
|
+
const dashUrl = `localhost:${proxyPort}${DASHBOARD_PREFIX}`;
|
|
5229
|
+
return (insights) => {
|
|
5230
|
+
const lines = [];
|
|
5231
|
+
for (const insight of insights) {
|
|
5232
|
+
if (insight.severity === "info") continue;
|
|
5233
|
+
const endpoint = insight.desc.match(/^(\S+\s+\S+)/)?.[1] ?? insight.desc;
|
|
5234
|
+
const key = `${insight.type}:${endpoint}`;
|
|
5235
|
+
if (printedKeys.has(key)) continue;
|
|
5236
|
+
printedKeys.add(key);
|
|
5237
|
+
let suffix;
|
|
5238
|
+
if (insight.type === "slow") {
|
|
5239
|
+
const ep = metricsStore.getAll().find((e) => e.endpoint === endpoint);
|
|
5240
|
+
if (ep && ep.sessions.length > 1) {
|
|
5241
|
+
const prev = ep.sessions[ep.sessions.length - 2];
|
|
5242
|
+
suffix = ` (\u2191 from ${prev.p95DurationMs < 1e3 ? prev.p95DurationMs + "ms" : (prev.p95DurationMs / 1e3).toFixed(1) + "s"})`;
|
|
5243
|
+
}
|
|
5244
|
+
}
|
|
5245
|
+
lines.push(formatConsoleLine(insight, dashUrl, suffix));
|
|
5246
|
+
}
|
|
5247
|
+
if (lines.length > 0) {
|
|
5248
|
+
console.log();
|
|
5249
|
+
for (const line of lines) console.log(line);
|
|
5250
|
+
}
|
|
5251
|
+
};
|
|
5252
|
+
}
|
|
5503
5253
|
|
|
5504
5254
|
// src/process/spawn.ts
|
|
5505
5255
|
import { spawn } from "child_process";
|
|
@@ -5546,9 +5296,44 @@ function spawnCustomCommand(command, targetPort, proxyPort, cwd) {
|
|
|
5546
5296
|
});
|
|
5547
5297
|
}
|
|
5548
5298
|
|
|
5299
|
+
// src/process/port.ts
|
|
5300
|
+
import { createServer as createServer2 } from "net";
|
|
5301
|
+
function isPortFree(port) {
|
|
5302
|
+
return new Promise((resolve5) => {
|
|
5303
|
+
const server = createServer2();
|
|
5304
|
+
server.once("error", () => resolve5(false));
|
|
5305
|
+
server.once("listening", () => {
|
|
5306
|
+
server.close(() => resolve5(true));
|
|
5307
|
+
});
|
|
5308
|
+
server.listen(port);
|
|
5309
|
+
});
|
|
5310
|
+
}
|
|
5311
|
+
async function findFreePortPair(startPort, maxAttempts = 10) {
|
|
5312
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
5313
|
+
const port = startPort + i;
|
|
5314
|
+
const proxyFree = await isPortFree(port);
|
|
5315
|
+
if (!proxyFree) continue;
|
|
5316
|
+
const targetFree = await isPortFree(port + 1);
|
|
5317
|
+
if (!targetFree) continue;
|
|
5318
|
+
return port;
|
|
5319
|
+
}
|
|
5320
|
+
throw new Error(`Could not find a free port pair starting from ${startPort}`);
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5549
5323
|
// src/lifecycle/startup.ts
|
|
5550
5324
|
async function startBrakit(opts) {
|
|
5551
|
-
const { rootDir,
|
|
5325
|
+
const { rootDir, showStatic, customCommand } = opts;
|
|
5326
|
+
let proxyPort;
|
|
5327
|
+
try {
|
|
5328
|
+
proxyPort = await findFreePortPair(opts.proxyPort);
|
|
5329
|
+
} catch {
|
|
5330
|
+
console.error(pc2.red(`
|
|
5331
|
+
Could not find a free port starting from ${opts.proxyPort}.`));
|
|
5332
|
+
process.exit(1);
|
|
5333
|
+
}
|
|
5334
|
+
if (proxyPort !== opts.proxyPort) {
|
|
5335
|
+
console.log(pc2.yellow(` Port ${opts.proxyPort} is in use, using ${proxyPort} instead.`));
|
|
5336
|
+
}
|
|
5552
5337
|
const targetPort = proxyPort + 1;
|
|
5553
5338
|
let project;
|
|
5554
5339
|
if (customCommand) {
|
|
@@ -5584,25 +5369,33 @@ async function startBrakit(opts) {
|
|
|
5584
5369
|
metricsStore.start();
|
|
5585
5370
|
const analysisEngine = new AnalysisEngine();
|
|
5586
5371
|
analysisEngine.start();
|
|
5372
|
+
analysisEngine.onUpdate(createConsoleInsightListener(proxyPort, metricsStore));
|
|
5373
|
+
if (isTelemetryEnabled()) {
|
|
5374
|
+
initSession(project.framework, project.packageManager, !!customCommand, []);
|
|
5375
|
+
analysisEngine.onUpdate((insights, findings) => {
|
|
5376
|
+
recordInsightTypes(insights.map((i) => i.type));
|
|
5377
|
+
recordRulesTriggered(findings.map((f) => f.rule));
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
5587
5380
|
const handleDashboard = createDashboardHandler({ metricsStore, analysisEngine });
|
|
5588
5381
|
console.log(pc2.dim(` Starting ${project.devCommand} on port ${targetPort}...`));
|
|
5589
5382
|
const devProcess = customCommand ? spawnCustomCommand(customCommand, targetPort, proxyPort, rootDir) : spawnDevServer(project.devBin, targetPort, proxyPort, rootDir);
|
|
5590
5383
|
const proxy = createProxyServer(config, handleDashboard);
|
|
5591
5384
|
proxy.on("error", (err) => {
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
devProcess.kill("SIGTERM");
|
|
5597
|
-
process.exit(1);
|
|
5598
|
-
}
|
|
5385
|
+
devProcess.kill("SIGTERM");
|
|
5386
|
+
console.error(pc2.red(`
|
|
5387
|
+
Proxy failed to start: ${err.message}`));
|
|
5388
|
+
process.exit(1);
|
|
5599
5389
|
});
|
|
5600
5390
|
proxy.listen(proxyPort, () => {
|
|
5601
5391
|
printBanner(proxyPort, targetPort);
|
|
5602
5392
|
});
|
|
5393
|
+
let reqCount = 0;
|
|
5603
5394
|
onRequest((req) => {
|
|
5604
5395
|
const queryCount = defaultQueryStore.getByRequest(req.id).length;
|
|
5605
5396
|
metricsStore.recordRequest(req, queryCount);
|
|
5397
|
+
reqCount++;
|
|
5398
|
+
recordRequestCount(reqCount);
|
|
5606
5399
|
});
|
|
5607
5400
|
return { proxy, devProcess, metricsStore, analysisEngine, config, project };
|
|
5608
5401
|
}
|
|
@@ -5615,6 +5408,7 @@ function createShutdownHandler(instance) {
|
|
|
5615
5408
|
if (shuttingDown) return;
|
|
5616
5409
|
shuttingDown = true;
|
|
5617
5410
|
console.log(pc3.dim("\n Shutting down..."));
|
|
5411
|
+
trackSession(instance.metricsStore, instance.analysisEngine);
|
|
5618
5412
|
instance.analysisEngine.stop();
|
|
5619
5413
|
instance.metricsStore.stop();
|
|
5620
5414
|
instance.proxy.close();
|
|
@@ -5675,5 +5469,54 @@ var dev_default = defineCommand({
|
|
|
5675
5469
|
}
|
|
5676
5470
|
});
|
|
5677
5471
|
|
|
5472
|
+
// src/cli/commands/telemetry.ts
|
|
5473
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
5474
|
+
import pc5 from "picocolors";
|
|
5475
|
+
var telemetry_default = defineCommand2({
|
|
5476
|
+
meta: {
|
|
5477
|
+
name: "telemetry",
|
|
5478
|
+
description: "Manage anonymous telemetry settings"
|
|
5479
|
+
},
|
|
5480
|
+
args: {
|
|
5481
|
+
action: {
|
|
5482
|
+
type: "positional",
|
|
5483
|
+
description: "on | off | status",
|
|
5484
|
+
required: false
|
|
5485
|
+
}
|
|
5486
|
+
},
|
|
5487
|
+
run({ args }) {
|
|
5488
|
+
const action = args.action?.toLowerCase();
|
|
5489
|
+
if (action === "on") {
|
|
5490
|
+
setTelemetryEnabled(true);
|
|
5491
|
+
console.log(pc5.green(" Telemetry enabled."));
|
|
5492
|
+
return;
|
|
5493
|
+
}
|
|
5494
|
+
if (action === "off") {
|
|
5495
|
+
setTelemetryEnabled(false);
|
|
5496
|
+
console.log(pc5.yellow(" Telemetry disabled. No data will be collected."));
|
|
5497
|
+
return;
|
|
5498
|
+
}
|
|
5499
|
+
const enabled = isTelemetryEnabled();
|
|
5500
|
+
console.log();
|
|
5501
|
+
console.log(` ${pc5.bold("Telemetry")}: ${enabled ? pc5.green("enabled") : pc5.yellow("disabled")}`);
|
|
5502
|
+
console.log();
|
|
5503
|
+
console.log(pc5.dim(" brakit collects anonymous usage data to improve the tool."));
|
|
5504
|
+
console.log(pc5.dim(" No URLs, queries, bodies, or source code are ever sent."));
|
|
5505
|
+
console.log();
|
|
5506
|
+
console.log(pc5.dim(" Opt out: ") + pc5.bold("brakit telemetry off"));
|
|
5507
|
+
console.log(pc5.dim(" Opt in: ") + pc5.bold("brakit telemetry on"));
|
|
5508
|
+
console.log(pc5.dim(" Env override: BRAKIT_TELEMETRY=false"));
|
|
5509
|
+
console.log();
|
|
5510
|
+
}
|
|
5511
|
+
});
|
|
5512
|
+
|
|
5678
5513
|
// bin/brakit.ts
|
|
5679
|
-
|
|
5514
|
+
if (process.argv[2] === "telemetry") {
|
|
5515
|
+
process.argv.splice(2, 1);
|
|
5516
|
+
runMain(telemetry_default);
|
|
5517
|
+
} else {
|
|
5518
|
+
if (process.argv[2] === "dev") {
|
|
5519
|
+
process.argv.splice(2, 1);
|
|
5520
|
+
}
|
|
5521
|
+
runMain(dev_default);
|
|
5522
|
+
}
|