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.
@@ -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 startTime = performance.now();
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
- startTime,
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, startTime, bodyChunks, config, requestId) {
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 startTime = first.startedAt;
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 - startTime),
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 handleSSE(req, res) {
1228
- res.writeHead(200, {
1229
- "content-type": "text/event-stream",
1230
- "cache-control": "no-cache",
1231
- connection: "keep-alive",
1232
- "access-control-allow-origin": "*"
1233
- });
1234
- res.write(":ok\n\n");
1235
- const writeEvent = (eventType, data) => {
1236
- if (res.destroyed) return;
1237
- if (eventType) {
1238
- res.write(`event: ${eventType}
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
- } else {
1243
- res.write(`data: ${data}
1246
+ } else {
1247
+ res.write(`data: ${data}
1244
1248
 
1245
1249
  `);
1246
- }
1247
- };
1248
- const requestListener = (traced) => {
1249
- writeEvent(null, JSON.stringify(traced));
1250
- };
1251
- const fetchListener = (entry) => {
1252
- writeEvent("fetch", JSON.stringify(entry));
1253
- };
1254
- const logListener = (entry) => {
1255
- writeEvent("log", JSON.stringify(entry));
1256
- };
1257
- const errorListener = (entry) => {
1258
- writeEvent("error_event", JSON.stringify(entry));
1259
- };
1260
- const queryListener = (entry) => {
1261
- writeEvent("query", JSON.stringify(entry));
1262
- };
1263
- onRequest(requestListener);
1264
- defaultFetchStore.onEntry(fetchListener);
1265
- defaultLogStore.onEntry(logListener);
1266
- defaultErrorStore.onEntry(errorListener);
1267
- defaultQueryStore.onEntry(queryListener);
1268
- const heartbeat = setInterval(() => {
1269
- if (res.destroyed) {
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
- return;
1272
- }
1273
- res.write(":heartbeat\n\n");
1274
- }, SSE_HEARTBEAT_INTERVAL_MS);
1275
- req.on("close", () => {
1276
- clearInterval(heartbeat);
1277
- offRequest(requestListener);
1278
- defaultFetchStore.offEntry(fetchListener);
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 = q.sql ? simplifySQL(q.sql) : { op: q.operation || '?', table: q.model || '' };
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 = d.sql ? simplifySQL(d.sql) : { op: d.operation || '?', table: d.model || '' };
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 = computeInsights();
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 getOverviewInsights() + getOverviewRender();
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 = computeSecurityFindings();
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 secFindings = computeSecurityFindings();
4512
- secCount.textContent = secFindings.length;
4513
- secCount.style.display = secFindings.length > 0 ? '' : 'none';
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]: handleSSE,
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 = join(rootDir, "package.json");
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 = join(rootDir, "node_modules", ".bin", f.bin);
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(join(rootDir, "bun.lockb"))) return "bun";
4675
- if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
4676
- if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
4677
- if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
4678
- if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
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.0";
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, proxyPort, showStatic, customCommand } = opts;
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
- if (err.code === "EADDRINUSE") {
5593
- console.error(pc2.red(`
5594
- Port ${proxyPort} is already in use.`));
5595
- console.error(pc2.dim(` Try: npx brakit --port ${proxyPort + 2}`));
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
- runMain(dev_default);
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
+ }