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/dist/index.d.ts CHANGED
@@ -142,7 +142,7 @@ declare class SecurityScanner {
142
142
  declare function createDefaultScanner(): SecurityScanner;
143
143
 
144
144
  type InsightSeverity = "critical" | "warning" | "info";
145
- type InsightType = "n1" | "cross-endpoint" | "redundant-query" | "error" | "error-hotspot" | "duplicate" | "slow" | "query-heavy" | "auth-overhead" | "select-star" | "high-rows" | "large-response" | "security";
145
+ type InsightType = "n1" | "cross-endpoint" | "redundant-query" | "error" | "error-hotspot" | "duplicate" | "slow" | "query-heavy" | "select-star" | "high-rows" | "large-response" | "response-overfetch" | "security";
146
146
  interface Insight {
147
147
  severity: InsightSeverity;
148
148
  type: InsightType;
package/dist/index.js CHANGED
@@ -24,7 +24,6 @@ var ERROR_RATE_THRESHOLD_PCT = 20;
24
24
  var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
25
25
  var MIN_REQUESTS_FOR_INSIGHT = 2;
26
26
  var HIGH_QUERY_COUNT_PER_REQ = 5;
27
- var AUTH_OVERHEAD_PCT = 30;
28
27
  var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
29
28
  var CROSS_ENDPOINT_PCT = 50;
30
29
  var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
@@ -32,6 +31,9 @@ var REDUNDANT_QUERY_MIN_COUNT = 2;
32
31
  var LARGE_RESPONSE_BYTES = 51200;
33
32
  var HIGH_ROW_COUNT = 100;
34
33
  var OVERFETCH_MIN_REQUESTS = 2;
34
+ var OVERFETCH_MIN_FIELDS = 8;
35
+ var OVERFETCH_MIN_INTERNAL_IDS = 2;
36
+ var OVERFETCH_NULL_RATIO = 0.3;
35
37
 
36
38
  // src/constants/headers.ts
37
39
  var BRAKIT_REQUEST_ID_HEADER = "x-brakit-request-id";
@@ -699,6 +701,17 @@ var HEALTH_GRADES = `[
699
701
  { max: Infinity, label: 'Critical', color: 'var(--red)', bg: 'rgba(220,38,38,0.08)', border: 'rgba(220,38,38,0.2)' }
700
702
  ]`;
701
703
 
704
+ // src/telemetry/index.ts
705
+ import { platform, release, arch } from "os";
706
+
707
+ // src/telemetry/config.ts
708
+ import { homedir } from "os";
709
+ import { join } from "path";
710
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
711
+ import { randomUUID as randomUUID5 } from "crypto";
712
+ var CONFIG_DIR = join(homedir(), ".brakit");
713
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
714
+
702
715
  // src/dashboard/router.ts
703
716
  function isDashboardRequest(url) {
704
717
  return url === DASHBOARD_PREFIX || url.startsWith(DASHBOARD_PREFIX + "/");
@@ -721,7 +734,7 @@ function createProxyServer(config, handleDashboard) {
721
734
 
722
735
  // src/detect/project.ts
723
736
  import { readFile as readFile2 } from "fs/promises";
724
- import { join } from "path";
737
+ import { join as join2 } from "path";
725
738
  var FRAMEWORKS = [
726
739
  { name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
727
740
  { name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
@@ -730,7 +743,7 @@ var FRAMEWORKS = [
730
743
  { name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
731
744
  ];
732
745
  async function detectProject(rootDir) {
733
- const pkgPath = join(rootDir, "package.json");
746
+ const pkgPath = join2(rootDir, "package.json");
734
747
  const raw = await readFile2(pkgPath, "utf-8");
735
748
  const pkg = JSON.parse(raw);
736
749
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
@@ -742,7 +755,7 @@ async function detectProject(rootDir) {
742
755
  if (allDeps[f.dep]) {
743
756
  framework = f.name;
744
757
  devCommand = f.devCmd;
745
- devBin = join(rootDir, "node_modules", ".bin", f.bin);
758
+ devBin = join2(rootDir, "node_modules", ".bin", f.bin);
746
759
  defaultPort = f.defaultPort;
747
760
  break;
748
761
  }
@@ -751,11 +764,11 @@ async function detectProject(rootDir) {
751
764
  return { framework, devCommand, devBin, defaultPort, packageManager };
752
765
  }
753
766
  async function detectPackageManager(rootDir) {
754
- if (await fileExists(join(rootDir, "bun.lockb"))) return "bun";
755
- if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
756
- if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
757
- if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
758
- if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
767
+ if (await fileExists(join2(rootDir, "bun.lockb"))) return "bun";
768
+ if (await fileExists(join2(rootDir, "bun.lock"))) return "bun";
769
+ if (await fileExists(join2(rootDir, "pnpm-lock.yaml"))) return "pnpm";
770
+ if (await fileExists(join2(rootDir, "yarn.lock"))) return "yarn";
771
+ if (await fileExists(join2(rootDir, "package-lock.json"))) return "npm";
759
772
  return "unknown";
760
773
  }
761
774
 
@@ -801,6 +814,9 @@ var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+S
801
814
  var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
802
815
  var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
803
816
  var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
817
+ var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
818
+ var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
819
+ var INTERNAL_ID_SUFFIX = /Id$|_id$/;
804
820
  var RULE_HINTS = {
805
821
  "exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
806
822
  "token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
@@ -808,7 +824,8 @@ var RULE_HINTS = {
808
824
  "error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
809
825
  "sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
810
826
  "cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
811
- "insecure-cookie": "Set HttpOnly and SameSite flags on all cookies."
827
+ "insecure-cookie": "Set HttpOnly and SameSite flags on all cookies.",
828
+ "response-pii-leak": "API responses should return minimal data. Don't echo back full user records \u2014 select only the fields the client needs."
812
829
  };
813
830
 
814
831
  // src/analysis/rules/exposed-secret.ts
@@ -1002,6 +1019,12 @@ var errorInfoLeakRule = {
1002
1019
  };
1003
1020
 
1004
1021
  // src/analysis/rules/insecure-cookie.ts
1022
+ function isFrameworkResponse(r) {
1023
+ if (r.statusCode >= 300 && r.statusCode < 400) return true;
1024
+ if (r.path?.startsWith("/__")) return true;
1025
+ if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
1026
+ return false;
1027
+ }
1005
1028
  var insecureCookieRule = {
1006
1029
  id: "insecure-cookie",
1007
1030
  severity: "warning",
@@ -1012,6 +1035,7 @@ var insecureCookieRule = {
1012
1035
  const seen = /* @__PURE__ */ new Map();
1013
1036
  for (const r of ctx.requests) {
1014
1037
  if (!r.responseHeaders) continue;
1038
+ if (isFrameworkResponse(r)) continue;
1015
1039
  const setCookie = r.responseHeaders["set-cookie"];
1016
1040
  if (!setCookie) continue;
1017
1041
  const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
@@ -1102,6 +1126,157 @@ var corsCredentialsRule = {
1102
1126
  }
1103
1127
  };
1104
1128
 
1129
+ // src/analysis/rules/response-pii-leak.ts
1130
+ var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
1131
+ var FULL_RECORD_MIN_FIELDS = 5;
1132
+ var LIST_PII_MIN_ITEMS = 2;
1133
+ function tryParseJson2(body) {
1134
+ if (!body) return null;
1135
+ try {
1136
+ return JSON.parse(body);
1137
+ } catch {
1138
+ return null;
1139
+ }
1140
+ }
1141
+ function findEmails(obj) {
1142
+ const emails = [];
1143
+ if (!obj || typeof obj !== "object") return emails;
1144
+ if (Array.isArray(obj)) {
1145
+ for (let i = 0; i < Math.min(obj.length, 10); i++) {
1146
+ emails.push(...findEmails(obj[i]));
1147
+ }
1148
+ return emails;
1149
+ }
1150
+ for (const v of Object.values(obj)) {
1151
+ if (typeof v === "string" && EMAIL_RE.test(v)) {
1152
+ emails.push(v);
1153
+ } else if (typeof v === "object" && v !== null) {
1154
+ emails.push(...findEmails(v));
1155
+ }
1156
+ }
1157
+ return emails;
1158
+ }
1159
+ function topLevelFieldCount(obj) {
1160
+ if (Array.isArray(obj)) {
1161
+ return obj.length > 0 ? topLevelFieldCount(obj[0]) : 0;
1162
+ }
1163
+ if (obj && typeof obj === "object") return Object.keys(obj).length;
1164
+ return 0;
1165
+ }
1166
+ function hasInternalIds(obj) {
1167
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false;
1168
+ for (const key of Object.keys(obj)) {
1169
+ if (INTERNAL_ID_KEYS.test(key) || INTERNAL_ID_SUFFIX.test(key)) return true;
1170
+ }
1171
+ return false;
1172
+ }
1173
+ function unwrapResponse(parsed) {
1174
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
1175
+ const obj = parsed;
1176
+ const keys = Object.keys(obj);
1177
+ if (keys.length > 3) return parsed;
1178
+ let best = null;
1179
+ let bestSize = 0;
1180
+ for (const key of keys) {
1181
+ const val = obj[key];
1182
+ if (Array.isArray(val) && val.length > bestSize) {
1183
+ best = val;
1184
+ bestSize = val.length;
1185
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
1186
+ const size = Object.keys(val).length;
1187
+ if (size > bestSize) {
1188
+ best = val;
1189
+ bestSize = size;
1190
+ }
1191
+ }
1192
+ }
1193
+ return best && bestSize >= 3 ? best : parsed;
1194
+ }
1195
+ function detectPII(method, reqBody, resBody) {
1196
+ const target = unwrapResponse(resBody);
1197
+ if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
1198
+ const reqEmails = findEmails(reqBody);
1199
+ if (reqEmails.length > 0) {
1200
+ const resEmails = findEmails(target);
1201
+ const echoed = reqEmails.filter((e) => resEmails.includes(e));
1202
+ if (echoed.length > 0) {
1203
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
1204
+ if (hasInternalIds(inspectObj) || topLevelFieldCount(inspectObj) >= FULL_RECORD_MIN_FIELDS) {
1205
+ return { reason: "echo", emailCount: echoed.length };
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+ if (target && typeof target === "object" && !Array.isArray(target)) {
1211
+ const fields = topLevelFieldCount(target);
1212
+ if (fields >= FULL_RECORD_MIN_FIELDS && hasInternalIds(target)) {
1213
+ const emails = findEmails(target);
1214
+ if (emails.length > 0) {
1215
+ return { reason: "full-record", emailCount: emails.length };
1216
+ }
1217
+ }
1218
+ }
1219
+ if (Array.isArray(target) && target.length >= LIST_PII_MIN_ITEMS) {
1220
+ let itemsWithEmail = 0;
1221
+ for (let i = 0; i < Math.min(target.length, 10); i++) {
1222
+ const item = target[i];
1223
+ if (item && typeof item === "object") {
1224
+ const emails = findEmails(item);
1225
+ if (emails.length > 0) itemsWithEmail++;
1226
+ }
1227
+ }
1228
+ if (itemsWithEmail >= LIST_PII_MIN_ITEMS) {
1229
+ const first = target[0];
1230
+ if (hasInternalIds(first) || topLevelFieldCount(first) >= FULL_RECORD_MIN_FIELDS) {
1231
+ return { reason: "list-pii", emailCount: itemsWithEmail };
1232
+ }
1233
+ }
1234
+ }
1235
+ return null;
1236
+ }
1237
+ var REASON_LABELS = {
1238
+ echo: "echoes back PII from the request body",
1239
+ "full-record": "returns a full record with email and internal IDs",
1240
+ "list-pii": "returns a list of records containing email addresses"
1241
+ };
1242
+ var responsePiiLeakRule = {
1243
+ id: "response-pii-leak",
1244
+ severity: "warning",
1245
+ name: "PII Leak in Response",
1246
+ hint: RULE_HINTS["response-pii-leak"],
1247
+ check(ctx) {
1248
+ const findings = [];
1249
+ const seen = /* @__PURE__ */ new Map();
1250
+ for (const r of ctx.requests) {
1251
+ if (r.statusCode >= 400) continue;
1252
+ const resJson = tryParseJson2(r.responseBody);
1253
+ if (!resJson) continue;
1254
+ const reqJson = tryParseJson2(r.requestBody);
1255
+ const detection = detectPII(r.method, reqJson, resJson);
1256
+ if (!detection) continue;
1257
+ const ep = `${r.method} ${r.path}`;
1258
+ const dedupKey = `${ep}:${detection.reason}`;
1259
+ const existing = seen.get(dedupKey);
1260
+ if (existing) {
1261
+ existing.count++;
1262
+ continue;
1263
+ }
1264
+ const finding = {
1265
+ severity: "warning",
1266
+ rule: "response-pii-leak",
1267
+ title: "PII Leak in Response",
1268
+ desc: `${ep} \u2014 ${REASON_LABELS[detection.reason]}`,
1269
+ hint: this.hint,
1270
+ endpoint: ep,
1271
+ count: 1
1272
+ };
1273
+ seen.set(dedupKey, finding);
1274
+ findings.push(finding);
1275
+ }
1276
+ return findings;
1277
+ }
1278
+ };
1279
+
1105
1280
  // src/analysis/rules/scanner.ts
1106
1281
  var SecurityScanner = class {
1107
1282
  rules = [];
@@ -1131,6 +1306,7 @@ function createDefaultScanner() {
1131
1306
  scanner.register(insecureCookieRule);
1132
1307
  scanner.register(sensitiveLogsRule);
1133
1308
  scanner.register(corsCredentialsRule);
1309
+ scanner.register(responsePiiLeakRule);
1134
1310
  return scanner;
1135
1311
  }
1136
1312
 
@@ -1167,7 +1343,6 @@ function normalizeQueryParams(sql) {
1167
1343
  }
1168
1344
 
1169
1345
  // src/analysis/insights.ts
1170
- var AUTH_CATEGORIES = /* @__PURE__ */ new Set(["auth-handshake", "auth-check", "middleware"]);
1171
1346
  function getQueryShape(q) {
1172
1347
  if (q.sql) return normalizeQueryParams(q.sql) ?? "";
1173
1348
  return `${q.operation ?? q.normalizedOp ?? "?"}:${q.model ?? q.table ?? ""}`;
@@ -1409,29 +1584,6 @@ function computeInsights(ctx) {
1409
1584
  });
1410
1585
  }
1411
1586
  }
1412
- for (const flow of ctx.flows) {
1413
- if (!flow.requests || flow.requests.length < 2) continue;
1414
- let authMs = 0;
1415
- let totalMs = 0;
1416
- for (const r of flow.requests) {
1417
- const dur = r.pollingDurationMs ?? r.durationMs;
1418
- totalMs += dur;
1419
- if (AUTH_CATEGORIES.has(r.category ?? "")) authMs += dur;
1420
- }
1421
- if (totalMs > 0 && authMs > 0) {
1422
- const pct = Math.round(authMs / totalMs * 100);
1423
- if (pct >= AUTH_OVERHEAD_PCT) {
1424
- insights.push({
1425
- severity: "warning",
1426
- type: "auth-overhead",
1427
- title: "Auth Overhead",
1428
- desc: `${flow.label} \u2014 ${pct}% of time (${formatDuration(authMs)}) spent in auth/middleware`,
1429
- hint: "Auth checks consume a significant portion of this action. If using a third-party auth provider, check if session caching can reduce roundtrips.",
1430
- nav: "actions"
1431
- });
1432
- }
1433
- }
1434
- }
1435
1587
  const selectStarSeen = /* @__PURE__ */ new Map();
1436
1588
  for (const [, reqQueries] of queriesByReq) {
1437
1589
  for (const q of reqQueries) {
@@ -1480,6 +1632,69 @@ function computeInsights(ctx) {
1480
1632
  nav: "queries"
1481
1633
  });
1482
1634
  }
1635
+ const overfetchSeen = /* @__PURE__ */ new Set();
1636
+ for (const r of nonStatic) {
1637
+ if (r.statusCode >= 400 || !r.responseBody) continue;
1638
+ const ep = `${r.method} ${r.path}`;
1639
+ if (overfetchSeen.has(ep)) continue;
1640
+ let parsed;
1641
+ try {
1642
+ parsed = JSON.parse(r.responseBody);
1643
+ } catch {
1644
+ continue;
1645
+ }
1646
+ let target = parsed;
1647
+ if (target && typeof target === "object" && !Array.isArray(target)) {
1648
+ const topKeys = Object.keys(target);
1649
+ if (topKeys.length <= 3) {
1650
+ let best = null;
1651
+ let bestSize = 0;
1652
+ for (const k of topKeys) {
1653
+ const val = target[k];
1654
+ if (Array.isArray(val) && val.length > bestSize) {
1655
+ best = val;
1656
+ bestSize = val.length;
1657
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
1658
+ const size = Object.keys(val).length;
1659
+ if (size > bestSize) {
1660
+ best = val;
1661
+ bestSize = size;
1662
+ }
1663
+ }
1664
+ }
1665
+ if (best && bestSize >= 3) target = best;
1666
+ }
1667
+ }
1668
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
1669
+ if (!inspectObj || typeof inspectObj !== "object" || Array.isArray(inspectObj)) continue;
1670
+ const fields = Object.keys(inspectObj);
1671
+ if (fields.length < OVERFETCH_MIN_FIELDS) continue;
1672
+ let internalIdCount = 0;
1673
+ let nullCount = 0;
1674
+ for (const key of fields) {
1675
+ if (INTERNAL_ID_SUFFIX.test(key) || key === "id" || key === "_id") internalIdCount++;
1676
+ const val = inspectObj[key];
1677
+ if (val === null || val === void 0) nullCount++;
1678
+ }
1679
+ const nullRatio = nullCount / fields.length;
1680
+ const reasons = [];
1681
+ if (internalIdCount >= OVERFETCH_MIN_INTERNAL_IDS) reasons.push(`${internalIdCount} internal ID fields`);
1682
+ if (nullRatio >= OVERFETCH_NULL_RATIO) reasons.push(`${Math.round(nullRatio * 100)}% null fields`);
1683
+ if (fields.length >= OVERFETCH_MIN_FIELDS && reasons.length === 0 && fields.length >= 12) {
1684
+ reasons.push(`${fields.length} fields returned`);
1685
+ }
1686
+ if (reasons.length > 0) {
1687
+ overfetchSeen.add(ep);
1688
+ insights.push({
1689
+ severity: "info",
1690
+ type: "response-overfetch",
1691
+ title: "Response Overfetch",
1692
+ desc: `${ep} \u2014 ${reasons.join(", ")}`,
1693
+ 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.",
1694
+ nav: "requests"
1695
+ });
1696
+ }
1697
+ }
1483
1698
  for (const [ep, g] of endpointGroups) {
1484
1699
  if (g.total < OVERFETCH_MIN_REQUESTS) continue;
1485
1700
  const avgSize = Math.round(g.totalSize / g.total);
@@ -1590,7 +1805,7 @@ var AnalysisEngine = class {
1590
1805
  };
1591
1806
 
1592
1807
  // src/index.ts
1593
- var VERSION = "0.6.0";
1808
+ var VERSION = "0.6.2";
1594
1809
  export {
1595
1810
  AdapterRegistry,
1596
1811
  AnalysisEngine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brakit",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "See what your API is really doing. Security scanning, N+1 detection, duplicate calls, DB queries — one command, zero config.",
5
5
  "type": "module",
6
6
  "bin": {