brakit 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/bin/brakit.js +646 -803
- package/dist/index.d.ts +1 -1
- package/dist/index.js +250 -35
- package/package.json +1 -1
package/dist/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" | "
|
|
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 =
|
|
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 =
|
|
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(
|
|
755
|
-
if (await fileExists(
|
|
756
|
-
if (await fileExists(
|
|
757
|
-
if (await fileExists(
|
|
758
|
-
if (await fileExists(
|
|
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.
|
|
1808
|
+
var VERSION = "0.6.2";
|
|
1594
1809
|
export {
|
|
1595
1810
|
AdapterRegistry,
|
|
1596
1811
|
AnalysisEngine,
|
package/package.json
CHANGED