brakit 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/{index.d.ts → api.d.ts} +1 -6
- package/dist/{index.js → api.js} +962 -1150
- package/dist/bin/brakit.js +437 -4940
- package/dist/runtime/index.js +6490 -0
- package/package.json +8 -7
- package/dist/instrument/preload.js +0 -739
- /package/dist/{instrument/preload.d.ts → runtime/index.d.ts} +0 -0
package/dist/{index.js → api.js}
RENAMED
|
@@ -1,614 +1,711 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
// src/proxy/handler.ts
|
|
5
|
-
import {
|
|
6
|
-
request as httpRequest
|
|
7
|
-
} from "http";
|
|
8
|
-
import { randomUUID } from "crypto";
|
|
9
|
-
|
|
10
|
-
// src/constants/routes.ts
|
|
11
|
-
var DASHBOARD_PREFIX = "/__brakit";
|
|
12
|
-
|
|
13
|
-
// src/constants/limits.ts
|
|
14
|
-
var MAX_REQUEST_ENTRIES = 1e3;
|
|
15
|
-
var MAX_TELEMETRY_ENTRIES = 1e3;
|
|
16
|
-
|
|
17
|
-
// src/constants/thresholds.ts
|
|
18
|
-
var FLOW_GAP_MS = 5e3;
|
|
19
|
-
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
20
|
-
var MIN_POLLING_SEQUENCE = 3;
|
|
21
|
-
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
22
|
-
var N1_QUERY_THRESHOLD = 5;
|
|
23
|
-
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
24
|
-
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
25
|
-
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
26
|
-
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
27
|
-
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
28
|
-
var CROSS_ENDPOINT_PCT = 50;
|
|
29
|
-
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
30
|
-
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
31
|
-
var LARGE_RESPONSE_BYTES = 51200;
|
|
32
|
-
var HIGH_ROW_COUNT = 100;
|
|
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;
|
|
37
|
-
|
|
38
|
-
// src/constants/headers.ts
|
|
39
|
-
var BRAKIT_REQUEST_ID_HEADER = "x-brakit-request-id";
|
|
1
|
+
// src/detect/project.ts
|
|
2
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
40
4
|
|
|
41
|
-
// src/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
5
|
+
// src/utils/fs.ts
|
|
6
|
+
import { access } from "fs/promises";
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
async function fileExists(path) {
|
|
10
|
+
try {
|
|
11
|
+
await access(path);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
50
16
|
}
|
|
51
17
|
|
|
52
|
-
// src/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
18
|
+
// src/detect/project.ts
|
|
19
|
+
var FRAMEWORKS = [
|
|
20
|
+
{ name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
21
|
+
{ name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
|
|
22
|
+
{ name: "nuxt", dep: "nuxt", devCmd: "nuxt dev", bin: "nuxt", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
23
|
+
{ name: "vite", dep: "vite", devCmd: "vite", bin: "vite", defaultPort: 5173, devArgs: ["--port"] },
|
|
24
|
+
{ name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
|
|
25
|
+
];
|
|
26
|
+
async function detectProject(rootDir) {
|
|
27
|
+
const pkgPath = join(rootDir, "package.json");
|
|
28
|
+
const raw = await readFile2(pkgPath, "utf-8");
|
|
29
|
+
const pkg = JSON.parse(raw);
|
|
30
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
31
|
+
let framework = "unknown";
|
|
32
|
+
let devCommand = "";
|
|
33
|
+
let devBin = "";
|
|
34
|
+
let defaultPort = 3e3;
|
|
35
|
+
for (const f of FRAMEWORKS) {
|
|
36
|
+
if (allDeps[f.dep]) {
|
|
37
|
+
framework = f.name;
|
|
38
|
+
devCommand = f.devCmd;
|
|
39
|
+
devBin = join(rootDir, "node_modules", ".bin", f.bin);
|
|
40
|
+
defaultPort = f.defaultPort;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
58
43
|
}
|
|
59
|
-
|
|
44
|
+
const packageManager = await detectPackageManager(rootDir);
|
|
45
|
+
return { framework, devCommand, devBin, defaultPort, packageManager };
|
|
60
46
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
47
|
+
async function detectPackageManager(rootDir) {
|
|
48
|
+
if (await fileExists(join(rootDir, "bun.lockb"))) return "bun";
|
|
49
|
+
if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
|
|
50
|
+
if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
51
|
+
if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
|
|
52
|
+
if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
|
|
53
|
+
return "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/instrument/adapter-registry.ts
|
|
57
|
+
var AdapterRegistry = class {
|
|
58
|
+
adapters = [];
|
|
59
|
+
active = [];
|
|
60
|
+
register(adapter) {
|
|
61
|
+
this.adapters.push(adapter);
|
|
64
62
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
let responseBodyStr = null;
|
|
75
|
-
if (input.responseBody && input.responseBody.length > 0) {
|
|
76
|
-
const ct = input.responseContentType;
|
|
77
|
-
if (ct.includes("json") || ct.includes("text") || ct.includes("html")) {
|
|
78
|
-
responseBodyStr = input.responseBody.subarray(0, input.config.maxBodyCapture).toString("utf-8");
|
|
63
|
+
patchAll(emit) {
|
|
64
|
+
for (const adapter of this.adapters) {
|
|
65
|
+
try {
|
|
66
|
+
if (adapter.detect()) {
|
|
67
|
+
adapter.patch(emit);
|
|
68
|
+
this.active.push(adapter);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
79
71
|
}
|
|
80
72
|
}
|
|
81
|
-
const entry = {
|
|
82
|
-
id: input.requestId,
|
|
83
|
-
method: input.method,
|
|
84
|
-
url,
|
|
85
|
-
path,
|
|
86
|
-
headers: flattenHeaders(input.requestHeaders),
|
|
87
|
-
requestBody: requestBodyStr,
|
|
88
|
-
statusCode: input.statusCode,
|
|
89
|
-
responseHeaders: flattenHeaders(input.responseHeaders),
|
|
90
|
-
responseBody: responseBodyStr,
|
|
91
|
-
startedAt: input.startTime,
|
|
92
|
-
durationMs: Math.round(performance.now() - input.startTime),
|
|
93
|
-
responseSize: input.responseBody?.length ?? 0,
|
|
94
|
-
isStatic: isStaticPath(path)
|
|
95
|
-
};
|
|
96
|
-
this.requests.push(entry);
|
|
97
|
-
if (this.requests.length > this.maxEntries) {
|
|
98
|
-
this.requests.shift();
|
|
99
|
-
}
|
|
100
|
-
for (const fn of this.listeners) {
|
|
101
|
-
fn(entry);
|
|
102
|
-
}
|
|
103
|
-
return entry;
|
|
104
73
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
this.
|
|
74
|
+
unpatchAll() {
|
|
75
|
+
for (const adapter of this.active) {
|
|
76
|
+
try {
|
|
77
|
+
adapter.unpatch?.();
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.active = [];
|
|
113
82
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
83
|
+
getActive() {
|
|
84
|
+
return this.active;
|
|
117
85
|
}
|
|
118
86
|
};
|
|
119
87
|
|
|
120
|
-
// src/
|
|
121
|
-
var
|
|
122
|
-
var
|
|
123
|
-
var
|
|
124
|
-
var
|
|
125
|
-
var
|
|
88
|
+
// src/analysis/rules/patterns.ts
|
|
89
|
+
var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
|
|
90
|
+
var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
|
|
91
|
+
var SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
|
|
92
|
+
var STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections/;
|
|
93
|
+
var DB_CONN_RE = /(postgres|mysql|mongodb|redis):\/\//;
|
|
94
|
+
var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i;
|
|
95
|
+
var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
|
|
96
|
+
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
|
|
97
|
+
var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
98
|
+
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
99
|
+
var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
|
|
100
|
+
var INTERNAL_ID_SUFFIX = /Id$|_id$/;
|
|
101
|
+
var RULE_HINTS = {
|
|
102
|
+
"exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
|
|
103
|
+
"token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
|
|
104
|
+
"stack-trace-leak": "Use a custom error handler that returns generic messages in production.",
|
|
105
|
+
"error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
|
|
106
|
+
"sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
|
|
107
|
+
"cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
|
|
108
|
+
"insecure-cookie": "Set HttpOnly and SameSite flags on all cookies.",
|
|
109
|
+
"response-pii-leak": "API responses should return minimal data. Don't echo back full user records \u2014 select only the fields the client needs."
|
|
110
|
+
};
|
|
126
111
|
|
|
127
|
-
// src/
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
let bodySize = 0;
|
|
135
|
-
if (shouldCaptureBody) {
|
|
136
|
-
clientReq.on("data", (chunk) => {
|
|
137
|
-
if (bodySize < config.maxBodyCapture) {
|
|
138
|
-
bodyChunks.push(chunk);
|
|
139
|
-
bodySize += chunk.length;
|
|
140
|
-
}
|
|
141
|
-
});
|
|
112
|
+
// src/analysis/rules/exposed-secret.ts
|
|
113
|
+
function tryParseJson(body) {
|
|
114
|
+
if (!body) return null;
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(body);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
142
119
|
}
|
|
143
|
-
const proxyHeaders = { ...clientReq.headers };
|
|
144
|
-
proxyHeaders["accept-encoding"] = "identity";
|
|
145
|
-
proxyHeaders[BRAKIT_REQUEST_ID_HEADER] = requestId;
|
|
146
|
-
const proxyReq = httpRequest(
|
|
147
|
-
{
|
|
148
|
-
hostname: "127.0.0.1",
|
|
149
|
-
port: config.targetPort,
|
|
150
|
-
path: clientReq.url,
|
|
151
|
-
method,
|
|
152
|
-
headers: proxyHeaders
|
|
153
|
-
},
|
|
154
|
-
(proxyRes) => {
|
|
155
|
-
handleProxyResponse(
|
|
156
|
-
clientReq,
|
|
157
|
-
clientRes,
|
|
158
|
-
proxyRes,
|
|
159
|
-
startTime,
|
|
160
|
-
shouldCaptureBody ? bodyChunks : [],
|
|
161
|
-
config,
|
|
162
|
-
requestId
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
);
|
|
166
|
-
proxyReq.on("error", (err) => {
|
|
167
|
-
if (clientRes.headersSent) return;
|
|
168
|
-
const code = err.code;
|
|
169
|
-
if (code === "ECONNREFUSED" || code === "ECONNRESET") {
|
|
170
|
-
clientRes.writeHead(502, { "content-type": "text/html" });
|
|
171
|
-
clientRes.end(
|
|
172
|
-
`<html><body style="font-family:system-ui;padding:40px;text-align:center"><h2>brakit</h2><p>Waiting for dev server on port ${config.targetPort}...</p><script>setTimeout(()=>location.reload(),2000)</script></body></html>`
|
|
173
|
-
);
|
|
174
|
-
} else {
|
|
175
|
-
clientRes.writeHead(502, { "content-type": "text/plain" });
|
|
176
|
-
clientRes.end(`brakit proxy error: ${err.message}
|
|
177
|
-
`);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
clientReq.pipe(proxyReq);
|
|
181
120
|
}
|
|
182
|
-
function
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (responseSize < config.maxBodyCapture) {
|
|
189
|
-
responseChunks.push(chunk);
|
|
190
|
-
responseSize += chunk.length;
|
|
121
|
+
function findSecretKeys(obj, prefix) {
|
|
122
|
+
const found = [];
|
|
123
|
+
if (!obj || typeof obj !== "object") return found;
|
|
124
|
+
if (Array.isArray(obj)) {
|
|
125
|
+
for (let i = 0; i < Math.min(obj.length, 5); i++) {
|
|
126
|
+
found.push(...findSecretKeys(obj[i], prefix));
|
|
191
127
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
requestId,
|
|
199
|
-
method: clientReq.method ?? "GET",
|
|
200
|
-
url: clientReq.url ?? "/",
|
|
201
|
-
requestHeaders: clientReq.headers,
|
|
202
|
-
requestBody,
|
|
203
|
-
statusCode: proxyRes.statusCode ?? 0,
|
|
204
|
-
responseHeaders: proxyRes.headers,
|
|
205
|
-
responseBody,
|
|
206
|
-
responseContentType: proxyRes.headers["content-type"] ?? "",
|
|
207
|
-
startTime,
|
|
208
|
-
config
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
proxyRes.on("error", () => {
|
|
212
|
-
clientRes.end();
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// src/proxy/websocket.ts
|
|
217
|
-
import {
|
|
218
|
-
request as httpRequest2
|
|
219
|
-
} from "http";
|
|
220
|
-
function handleUpgrade(req, clientSocket, head, targetPort) {
|
|
221
|
-
const targetReq = httpRequest2({
|
|
222
|
-
hostname: "127.0.0.1",
|
|
223
|
-
port: targetPort,
|
|
224
|
-
path: req.url,
|
|
225
|
-
method: req.method,
|
|
226
|
-
headers: req.headers
|
|
227
|
-
});
|
|
228
|
-
targetReq.on("upgrade", (_targetRes, targetSocket, targetHead) => {
|
|
229
|
-
const statusLine = `HTTP/1.1 101 Switching Protocols`;
|
|
230
|
-
const headerLines = [statusLine];
|
|
231
|
-
if (_targetRes.headers) {
|
|
232
|
-
for (const [key, value] of Object.entries(_targetRes.headers)) {
|
|
233
|
-
if (value === void 0) continue;
|
|
234
|
-
const vals = Array.isArray(value) ? value : [value];
|
|
235
|
-
for (const v of vals) {
|
|
236
|
-
headerLines.push(`${key}: ${v}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
for (const k of Object.keys(obj)) {
|
|
131
|
+
const val = obj[k];
|
|
132
|
+
if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= 8 && !MASKED_RE.test(val)) {
|
|
133
|
+
found.push(k);
|
|
239
134
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (targetHead.length > 0) {
|
|
243
|
-
clientSocket.write(targetHead);
|
|
135
|
+
if (typeof val === "object" && val !== null) {
|
|
136
|
+
found.push(...findSecretKeys(val, prefix + k + "."));
|
|
244
137
|
}
|
|
245
|
-
targetSocket.pipe(clientSocket);
|
|
246
|
-
clientSocket.pipe(targetSocket);
|
|
247
|
-
targetSocket.on("error", () => clientSocket.destroy());
|
|
248
|
-
clientSocket.on("error", () => targetSocket.destroy());
|
|
249
|
-
});
|
|
250
|
-
targetReq.on("error", () => {
|
|
251
|
-
clientSocket.destroy();
|
|
252
|
-
});
|
|
253
|
-
targetReq.write(head);
|
|
254
|
-
targetReq.end();
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// src/analysis/group.ts
|
|
258
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
259
|
-
|
|
260
|
-
// src/analysis/categorize.ts
|
|
261
|
-
function detectCategory(req) {
|
|
262
|
-
const { method, url, statusCode, responseHeaders } = req;
|
|
263
|
-
if (req.isStatic) return "static";
|
|
264
|
-
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
265
|
-
return "auth-handshake";
|
|
266
|
-
}
|
|
267
|
-
const effectivePath = getEffectivePath(req);
|
|
268
|
-
if (/^\/api\/auth/i.test(effectivePath) || /^\/(api\/)?clerk/i.test(effectivePath)) {
|
|
269
|
-
return "auth-check";
|
|
270
|
-
}
|
|
271
|
-
if (method === "POST" && !effectivePath.startsWith("/api/")) {
|
|
272
|
-
return "server-action";
|
|
273
|
-
}
|
|
274
|
-
if (effectivePath.startsWith("/api/") && method !== "GET" && method !== "HEAD") {
|
|
275
|
-
return "api-call";
|
|
276
138
|
}
|
|
277
|
-
|
|
278
|
-
return "data-fetch";
|
|
279
|
-
}
|
|
280
|
-
if (url.includes("_rsc=")) {
|
|
281
|
-
return "navigation";
|
|
282
|
-
}
|
|
283
|
-
if (responseHeaders["x-middleware-rewrite"]) {
|
|
284
|
-
return "middleware";
|
|
285
|
-
}
|
|
286
|
-
if (method === "GET") {
|
|
287
|
-
const ct = responseHeaders["content-type"] ?? "";
|
|
288
|
-
if (ct.includes("text/html")) return "page-load";
|
|
289
|
-
}
|
|
290
|
-
return "unknown";
|
|
139
|
+
return found;
|
|
291
140
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
141
|
+
var exposedSecretRule = {
|
|
142
|
+
id: "exposed-secret",
|
|
143
|
+
severity: "critical",
|
|
144
|
+
name: "Exposed Secret in Response",
|
|
145
|
+
hint: RULE_HINTS["exposed-secret"],
|
|
146
|
+
check(ctx) {
|
|
147
|
+
const findings = [];
|
|
148
|
+
const seen = /* @__PURE__ */ new Map();
|
|
149
|
+
for (const r of ctx.requests) {
|
|
150
|
+
if (r.statusCode >= 400) continue;
|
|
151
|
+
const parsed = tryParseJson(r.responseBody);
|
|
152
|
+
if (!parsed) continue;
|
|
153
|
+
const keys = findSecretKeys(parsed, "");
|
|
154
|
+
if (keys.length === 0) continue;
|
|
155
|
+
const ep = `${r.method} ${r.path}`;
|
|
156
|
+
const dedupKey = `${ep}:${keys.sort().join(",")}`;
|
|
157
|
+
const existing = seen.get(dedupKey);
|
|
158
|
+
if (existing) {
|
|
159
|
+
existing.count++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const finding = {
|
|
163
|
+
severity: "critical",
|
|
164
|
+
rule: "exposed-secret",
|
|
165
|
+
title: "Exposed Secret in Response",
|
|
166
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
167
|
+
hint: this.hint,
|
|
168
|
+
endpoint: ep,
|
|
169
|
+
count: 1
|
|
170
|
+
};
|
|
171
|
+
seen.set(dedupKey, finding);
|
|
172
|
+
findings.push(finding);
|
|
173
|
+
}
|
|
174
|
+
return findings;
|
|
300
175
|
}
|
|
301
|
-
}
|
|
176
|
+
};
|
|
302
177
|
|
|
303
|
-
// src/analysis/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
178
|
+
// src/analysis/rules/token-in-url.ts
|
|
179
|
+
var tokenInUrlRule = {
|
|
180
|
+
id: "token-in-url",
|
|
181
|
+
severity: "critical",
|
|
182
|
+
name: "Auth Token in URL",
|
|
183
|
+
hint: RULE_HINTS["token-in-url"],
|
|
184
|
+
check(ctx) {
|
|
185
|
+
const findings = [];
|
|
186
|
+
const seen = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const r of ctx.requests) {
|
|
188
|
+
const qIdx = r.url.indexOf("?");
|
|
189
|
+
if (qIdx === -1) continue;
|
|
190
|
+
const params = r.url.substring(qIdx + 1).split("&");
|
|
191
|
+
const flagged = [];
|
|
192
|
+
for (const param of params) {
|
|
193
|
+
const [name, ...rest] = param.split("=");
|
|
194
|
+
const val = rest.join("=");
|
|
195
|
+
if (SAFE_PARAMS.test(name)) continue;
|
|
196
|
+
if (TOKEN_PARAMS.test(name) && val && val.length > 0) {
|
|
197
|
+
flagged.push(name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (flagged.length === 0) continue;
|
|
201
|
+
const ep = `${r.method} ${r.path}`;
|
|
202
|
+
const dedupKey = `${ep}:${flagged.sort().join(",")}`;
|
|
203
|
+
const existing = seen.get(dedupKey);
|
|
204
|
+
if (existing) {
|
|
205
|
+
existing.count++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const finding = {
|
|
209
|
+
severity: "critical",
|
|
210
|
+
rule: "token-in-url",
|
|
211
|
+
title: "Auth Token in URL",
|
|
212
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
213
|
+
hint: this.hint,
|
|
214
|
+
endpoint: ep,
|
|
215
|
+
count: 1
|
|
216
|
+
};
|
|
217
|
+
seen.set(dedupKey, finding);
|
|
218
|
+
findings.push(finding);
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
312
221
|
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/analysis/rules/stack-trace-leak.ts
|
|
225
|
+
var stackTraceLeakRule = {
|
|
226
|
+
id: "stack-trace-leak",
|
|
227
|
+
severity: "critical",
|
|
228
|
+
name: "Stack Trace Leaked to Client",
|
|
229
|
+
hint: RULE_HINTS["stack-trace-leak"],
|
|
230
|
+
check(ctx) {
|
|
231
|
+
const findings = [];
|
|
232
|
+
const seen = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const r of ctx.requests) {
|
|
234
|
+
if (!r.responseBody) continue;
|
|
235
|
+
if (!STACK_TRACE_RE.test(r.responseBody)) continue;
|
|
236
|
+
const ep = `${r.method} ${r.path}`;
|
|
237
|
+
const existing = seen.get(ep);
|
|
238
|
+
if (existing) {
|
|
239
|
+
existing.count++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const finding = {
|
|
243
|
+
severity: "critical",
|
|
244
|
+
rule: "stack-trace-leak",
|
|
245
|
+
title: "Stack Trace Leaked to Client",
|
|
246
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
247
|
+
hint: this.hint,
|
|
248
|
+
endpoint: ep,
|
|
249
|
+
count: 1
|
|
250
|
+
};
|
|
251
|
+
seen.set(ep, finding);
|
|
252
|
+
findings.push(finding);
|
|
332
253
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
254
|
+
return findings;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/analysis/rules/error-info-leak.ts
|
|
259
|
+
var CRITICAL_PATTERNS = [
|
|
260
|
+
{ re: DB_CONN_RE, label: "database connection string" },
|
|
261
|
+
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
262
|
+
{ re: SECRET_VAL_RE, label: "secret value" }
|
|
263
|
+
];
|
|
264
|
+
var errorInfoLeakRule = {
|
|
265
|
+
id: "error-info-leak",
|
|
266
|
+
severity: "critical",
|
|
267
|
+
name: "Sensitive Data in Error Response",
|
|
268
|
+
hint: RULE_HINTS["error-info-leak"],
|
|
269
|
+
check(ctx) {
|
|
270
|
+
const findings = [];
|
|
271
|
+
const seen = /* @__PURE__ */ new Map();
|
|
272
|
+
for (const r of ctx.requests) {
|
|
273
|
+
if (r.statusCode < 400) continue;
|
|
274
|
+
if (!r.responseBody) continue;
|
|
275
|
+
if (r.responseHeaders["x-nextjs-error"] || r.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
276
|
+
const ep = `${r.method} ${r.path}`;
|
|
277
|
+
for (const p of CRITICAL_PATTERNS) {
|
|
278
|
+
if (!p.re.test(r.responseBody)) continue;
|
|
279
|
+
const dedupKey = `${ep}:${p.label}`;
|
|
280
|
+
const existing = seen.get(dedupKey);
|
|
281
|
+
if (existing) {
|
|
282
|
+
existing.count++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const finding = {
|
|
286
|
+
severity: "critical",
|
|
287
|
+
rule: "error-info-leak",
|
|
288
|
+
title: "Sensitive Data in Error Response",
|
|
289
|
+
desc: `${ep} \u2014 error response exposes ${p.label}`,
|
|
290
|
+
hint: this.hint,
|
|
291
|
+
endpoint: ep,
|
|
292
|
+
count: 1
|
|
293
|
+
};
|
|
294
|
+
seen.set(dedupKey, finding);
|
|
295
|
+
findings.push(finding);
|
|
296
|
+
}
|
|
336
297
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
298
|
+
return findings;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/analysis/rules/insecure-cookie.ts
|
|
303
|
+
function isFrameworkResponse(r) {
|
|
304
|
+
if (r.statusCode >= 300 && r.statusCode < 400) return true;
|
|
305
|
+
if (r.path?.startsWith("/__")) return true;
|
|
306
|
+
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
var insecureCookieRule = {
|
|
310
|
+
id: "insecure-cookie",
|
|
311
|
+
severity: "warning",
|
|
312
|
+
name: "Insecure Cookie",
|
|
313
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
314
|
+
check(ctx) {
|
|
315
|
+
const findings = [];
|
|
316
|
+
const seen = /* @__PURE__ */ new Map();
|
|
317
|
+
for (const r of ctx.requests) {
|
|
318
|
+
if (!r.responseHeaders) continue;
|
|
319
|
+
if (isFrameworkResponse(r)) continue;
|
|
320
|
+
const setCookie = r.responseHeaders["set-cookie"];
|
|
321
|
+
if (!setCookie) continue;
|
|
322
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
323
|
+
for (const cookie of cookies) {
|
|
324
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
325
|
+
const lower = cookie.toLowerCase();
|
|
326
|
+
const issues = [];
|
|
327
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
328
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
329
|
+
if (issues.length === 0) continue;
|
|
330
|
+
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
331
|
+
const existing = seen.get(dedupKey);
|
|
332
|
+
if (existing) {
|
|
333
|
+
existing.count++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const finding = {
|
|
337
|
+
severity: "warning",
|
|
338
|
+
rule: "insecure-cookie",
|
|
339
|
+
title: "Insecure Cookie",
|
|
340
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
341
|
+
hint: this.hint,
|
|
342
|
+
endpoint: cookieName,
|
|
343
|
+
count: 1
|
|
344
|
+
};
|
|
345
|
+
seen.set(dedupKey, finding);
|
|
346
|
+
findings.push(finding);
|
|
347
|
+
}
|
|
342
348
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
return findings;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// src/analysis/rules/sensitive-logs.ts
|
|
354
|
+
var sensitiveLogsRule = {
|
|
355
|
+
id: "sensitive-logs",
|
|
356
|
+
severity: "warning",
|
|
357
|
+
name: "Sensitive Data in Logs",
|
|
358
|
+
hint: RULE_HINTS["sensitive-logs"],
|
|
359
|
+
check(ctx) {
|
|
360
|
+
let count = 0;
|
|
361
|
+
for (const log of ctx.logs) {
|
|
362
|
+
if (!log.message) continue;
|
|
363
|
+
if (log.message.startsWith("[brakit]")) continue;
|
|
364
|
+
if (LOG_SECRET_RE.test(log.message)) count++;
|
|
347
365
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
366
|
+
if (count === 0) return [];
|
|
367
|
+
return [{
|
|
368
|
+
severity: "warning",
|
|
369
|
+
rule: "sensitive-logs",
|
|
370
|
+
title: "Sensitive Data in Logs",
|
|
371
|
+
desc: `Console output contains secret/token values \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
372
|
+
hint: this.hint,
|
|
373
|
+
endpoint: "console",
|
|
374
|
+
count
|
|
375
|
+
}];
|
|
356
376
|
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/analysis/rules/cors-credentials.ts
|
|
380
|
+
var corsCredentialsRule = {
|
|
381
|
+
id: "cors-credentials",
|
|
382
|
+
severity: "warning",
|
|
383
|
+
name: "CORS Credentials with Wildcard",
|
|
384
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
385
|
+
check(ctx) {
|
|
386
|
+
const findings = [];
|
|
387
|
+
const seen = /* @__PURE__ */ new Set();
|
|
388
|
+
for (const r of ctx.requests) {
|
|
389
|
+
if (!r.responseHeaders) continue;
|
|
390
|
+
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
391
|
+
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
392
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
393
|
+
const ep = `${r.method} ${r.path}`;
|
|
394
|
+
if (seen.has(ep)) continue;
|
|
395
|
+
seen.add(ep);
|
|
396
|
+
findings.push({
|
|
397
|
+
severity: "warning",
|
|
398
|
+
rule: "cors-credentials",
|
|
399
|
+
title: "CORS Credentials with Wildcard",
|
|
400
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
401
|
+
hint: this.hint,
|
|
402
|
+
endpoint: ep,
|
|
403
|
+
count: 1
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return findings;
|
|
387
407
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/analysis/rules/response-pii-leak.ts
|
|
411
|
+
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
412
|
+
var FULL_RECORD_MIN_FIELDS = 5;
|
|
413
|
+
var LIST_PII_MIN_ITEMS = 2;
|
|
414
|
+
function tryParseJson2(body) {
|
|
415
|
+
if (!body) return null;
|
|
416
|
+
try {
|
|
417
|
+
return JSON.parse(body);
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
398
420
|
}
|
|
399
421
|
}
|
|
400
|
-
function
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
422
|
+
function findEmails(obj) {
|
|
423
|
+
const emails = [];
|
|
424
|
+
if (!obj || typeof obj !== "object") return emails;
|
|
425
|
+
if (Array.isArray(obj)) {
|
|
426
|
+
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
|
427
|
+
emails.push(...findEmails(obj[i]));
|
|
428
|
+
}
|
|
429
|
+
return emails;
|
|
430
|
+
}
|
|
431
|
+
for (const v of Object.values(obj)) {
|
|
432
|
+
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
433
|
+
emails.push(v);
|
|
434
|
+
} else if (typeof v === "object" && v !== null) {
|
|
435
|
+
emails.push(...findEmails(v));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return emails;
|
|
409
439
|
}
|
|
410
|
-
function
|
|
411
|
-
|
|
440
|
+
function topLevelFieldCount(obj) {
|
|
441
|
+
if (Array.isArray(obj)) {
|
|
442
|
+
return obj.length > 0 ? topLevelFieldCount(obj[0]) : 0;
|
|
443
|
+
}
|
|
444
|
+
if (obj && typeof obj === "object") return Object.keys(obj).length;
|
|
445
|
+
return 0;
|
|
412
446
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
for (const req of requests) {
|
|
418
|
-
if (req.category !== "data-fetch" && req.category !== "auth-check")
|
|
419
|
-
continue;
|
|
420
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
421
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
447
|
+
function hasInternalIds(obj) {
|
|
448
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false;
|
|
449
|
+
for (const key of Object.keys(obj)) {
|
|
450
|
+
if (INTERNAL_ID_KEYS.test(key) || INTERNAL_ID_SUFFIX.test(key)) return true;
|
|
422
451
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
function unwrapResponse(parsed) {
|
|
455
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
456
|
+
const obj = parsed;
|
|
457
|
+
const keys = Object.keys(obj);
|
|
458
|
+
if (keys.length > 3) return parsed;
|
|
459
|
+
let best = null;
|
|
460
|
+
let bestSize = 0;
|
|
461
|
+
for (const key of keys) {
|
|
462
|
+
const val = obj[key];
|
|
463
|
+
if (Array.isArray(val) && val.length > bestSize) {
|
|
464
|
+
best = val;
|
|
465
|
+
bestSize = val.length;
|
|
466
|
+
} else if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
467
|
+
const size = Object.keys(val).length;
|
|
468
|
+
if (size > bestSize) {
|
|
469
|
+
best = val;
|
|
470
|
+
bestSize = size;
|
|
434
471
|
}
|
|
435
|
-
} else {
|
|
436
|
-
seen.add(key);
|
|
437
472
|
}
|
|
438
473
|
}
|
|
474
|
+
return best && bestSize >= 3 ? best : parsed;
|
|
439
475
|
}
|
|
440
|
-
function
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
476
|
+
function detectPII(method, reqBody, resBody) {
|
|
477
|
+
const target = unwrapResponse(resBody);
|
|
478
|
+
if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
|
|
479
|
+
const reqEmails = findEmails(reqBody);
|
|
480
|
+
if (reqEmails.length > 0) {
|
|
481
|
+
const resEmails = findEmails(target);
|
|
482
|
+
const echoed = reqEmails.filter((e) => resEmails.includes(e));
|
|
483
|
+
if (echoed.length > 0) {
|
|
484
|
+
const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
485
|
+
if (hasInternalIds(inspectObj) || topLevelFieldCount(inspectObj) >= FULL_RECORD_MIN_FIELDS) {
|
|
486
|
+
return { reason: "echo", emailCount: echoed.length };
|
|
487
|
+
}
|
|
450
488
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
label: `Polling ${endpointName} (${count}x, ${formatDurationLabel(pollingDuration)})`,
|
|
460
|
-
pollingCount: count,
|
|
461
|
-
pollingDurationMs: pollingDuration,
|
|
462
|
-
isDuplicate: false
|
|
463
|
-
});
|
|
464
|
-
i = j;
|
|
465
|
-
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (target && typeof target === "object" && !Array.isArray(target)) {
|
|
492
|
+
const fields = topLevelFieldCount(target);
|
|
493
|
+
if (fields >= FULL_RECORD_MIN_FIELDS && hasInternalIds(target)) {
|
|
494
|
+
const emails = findEmails(target);
|
|
495
|
+
if (emails.length > 0) {
|
|
496
|
+
return { reason: "full-record", emailCount: emails.length };
|
|
466
497
|
}
|
|
467
498
|
}
|
|
468
|
-
result.push(current);
|
|
469
|
-
i++;
|
|
470
499
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
500
|
+
if (Array.isArray(target) && target.length >= LIST_PII_MIN_ITEMS) {
|
|
501
|
+
let itemsWithEmail = 0;
|
|
502
|
+
for (let i = 0; i < Math.min(target.length, 10); i++) {
|
|
503
|
+
const item = target[i];
|
|
504
|
+
if (item && typeof item === "object") {
|
|
505
|
+
const emails = findEmails(item);
|
|
506
|
+
if (emails.length > 0) itemsWithEmail++;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (itemsWithEmail >= LIST_PII_MIN_ITEMS) {
|
|
510
|
+
const first = target[0];
|
|
511
|
+
if (hasInternalIds(first) || topLevelFieldCount(first) >= FULL_RECORD_MIN_FIELDS) {
|
|
512
|
+
return { reason: "list-pii", emailCount: itemsWithEmail };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
476
517
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
518
|
+
var REASON_LABELS = {
|
|
519
|
+
echo: "echoes back PII from the request body",
|
|
520
|
+
"full-record": "returns a full record with email and internal IDs",
|
|
521
|
+
"list-pii": "returns a list of records containing email addresses"
|
|
522
|
+
};
|
|
523
|
+
var responsePiiLeakRule = {
|
|
524
|
+
id: "response-pii-leak",
|
|
525
|
+
severity: "warning",
|
|
526
|
+
name: "PII Leak in Response",
|
|
527
|
+
hint: RULE_HINTS["response-pii-leak"],
|
|
528
|
+
check(ctx) {
|
|
529
|
+
const findings = [];
|
|
530
|
+
const seen = /* @__PURE__ */ new Map();
|
|
531
|
+
for (const r of ctx.requests) {
|
|
532
|
+
if (r.statusCode >= 400) continue;
|
|
533
|
+
const resJson = tryParseJson2(r.responseBody);
|
|
534
|
+
if (!resJson) continue;
|
|
535
|
+
const reqJson = tryParseJson2(r.requestBody);
|
|
536
|
+
const detection = detectPII(r.method, reqJson, resJson);
|
|
537
|
+
if (!detection) continue;
|
|
538
|
+
const ep = `${r.method} ${r.path}`;
|
|
539
|
+
const dedupKey = `${ep}:${detection.reason}`;
|
|
540
|
+
const existing = seen.get(dedupKey);
|
|
541
|
+
if (existing) {
|
|
542
|
+
existing.count++;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const finding = {
|
|
546
|
+
severity: "warning",
|
|
547
|
+
rule: "response-pii-leak",
|
|
548
|
+
title: "PII Leak in Response",
|
|
549
|
+
desc: `${ep} \u2014 ${REASON_LABELS[detection.reason]}`,
|
|
550
|
+
hint: this.hint,
|
|
551
|
+
endpoint: ep,
|
|
552
|
+
count: 1
|
|
553
|
+
};
|
|
554
|
+
seen.set(dedupKey, finding);
|
|
555
|
+
findings.push(finding);
|
|
556
|
+
}
|
|
557
|
+
return findings;
|
|
496
558
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// src/analysis/rules/scanner.ts
|
|
562
|
+
var SecurityScanner = class {
|
|
563
|
+
rules = [];
|
|
564
|
+
register(rule) {
|
|
565
|
+
this.rules.push(rule);
|
|
502
566
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
567
|
+
scan(ctx) {
|
|
568
|
+
const findings = [];
|
|
569
|
+
for (const rule of this.rules) {
|
|
570
|
+
try {
|
|
571
|
+
findings.push(...rule.check(ctx));
|
|
572
|
+
} catch {
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return findings;
|
|
506
576
|
}
|
|
507
|
-
|
|
577
|
+
getRules() {
|
|
578
|
+
return this.rules;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
function createDefaultScanner() {
|
|
582
|
+
const scanner = new SecurityScanner();
|
|
583
|
+
scanner.register(exposedSecretRule);
|
|
584
|
+
scanner.register(tokenInUrlRule);
|
|
585
|
+
scanner.register(stackTraceLeakRule);
|
|
586
|
+
scanner.register(errorInfoLeakRule);
|
|
587
|
+
scanner.register(insecureCookieRule);
|
|
588
|
+
scanner.register(sensitiveLogsRule);
|
|
589
|
+
scanner.register(corsCredentialsRule);
|
|
590
|
+
scanner.register(responsePiiLeakRule);
|
|
591
|
+
return scanner;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/constants/routes.ts
|
|
595
|
+
var DASHBOARD_PREFIX = "/__brakit";
|
|
596
|
+
|
|
597
|
+
// src/constants/limits.ts
|
|
598
|
+
var MAX_REQUEST_ENTRIES = 1e3;
|
|
599
|
+
var MAX_TELEMETRY_ENTRIES = 1e3;
|
|
600
|
+
|
|
601
|
+
// src/constants/thresholds.ts
|
|
602
|
+
var FLOW_GAP_MS = 5e3;
|
|
603
|
+
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
604
|
+
var MIN_POLLING_SEQUENCE = 3;
|
|
605
|
+
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
606
|
+
var N1_QUERY_THRESHOLD = 5;
|
|
607
|
+
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
608
|
+
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
609
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
610
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
611
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
612
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
613
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
614
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
615
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
616
|
+
var HIGH_ROW_COUNT = 100;
|
|
617
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
618
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
619
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
620
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
621
|
+
|
|
622
|
+
// src/utils/static-patterns.ts
|
|
623
|
+
var STATIC_PATTERNS = [
|
|
624
|
+
/^\/_next\//,
|
|
625
|
+
/\.(?:js|css|map|ico|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot)$/,
|
|
626
|
+
/^\/favicon/,
|
|
627
|
+
/^\/__nextjs/
|
|
628
|
+
];
|
|
629
|
+
function isStaticPath(urlPath) {
|
|
630
|
+
return STATIC_PATTERNS.some((p) => p.test(urlPath));
|
|
508
631
|
}
|
|
509
632
|
|
|
510
|
-
// src/
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
let lastEndTime = 0;
|
|
517
|
-
for (const req of requests) {
|
|
518
|
-
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
519
|
-
const labeled = labelRequest(req);
|
|
520
|
-
if (labeled.category === "static") continue;
|
|
521
|
-
const sourcePage = labeled.sourcePage;
|
|
522
|
-
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
523
|
-
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
524
|
-
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
525
|
-
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
526
|
-
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
527
|
-
flows.push(buildFlow(currentRequests));
|
|
528
|
-
currentRequests = [];
|
|
529
|
-
}
|
|
530
|
-
currentRequests.push(labeled);
|
|
531
|
-
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
532
|
-
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
533
|
-
}
|
|
534
|
-
if (currentRequests.length > 0) {
|
|
535
|
-
flows.push(buildFlow(currentRequests));
|
|
633
|
+
// src/store/request-store.ts
|
|
634
|
+
function flattenHeaders(headers) {
|
|
635
|
+
const flat = {};
|
|
636
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
637
|
+
if (value === void 0) continue;
|
|
638
|
+
flat[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
536
639
|
}
|
|
537
|
-
return
|
|
538
|
-
}
|
|
539
|
-
function buildFlow(rawRequests) {
|
|
540
|
-
markDuplicates(rawRequests);
|
|
541
|
-
const requests = collapsePolling(rawRequests);
|
|
542
|
-
const first = requests[0];
|
|
543
|
-
const startTime = first.startedAt;
|
|
544
|
-
const endTime = Math.max(
|
|
545
|
-
...requests.map(
|
|
546
|
-
(r) => r.pollingDurationMs ? r.startedAt + r.pollingDurationMs : r.startedAt + r.durationMs
|
|
547
|
-
)
|
|
548
|
-
);
|
|
549
|
-
const duplicateCount = rawRequests.filter((r) => r.isDuplicate).length;
|
|
550
|
-
const nonStaticCount = rawRequests.length;
|
|
551
|
-
const redundancyPct = nonStaticCount > 0 ? Math.round(duplicateCount / nonStaticCount * 100) : 0;
|
|
552
|
-
const sourcePage = getDominantSourcePage(rawRequests);
|
|
553
|
-
return {
|
|
554
|
-
id: randomUUID2(),
|
|
555
|
-
label: deriveFlowLabel(requests, sourcePage),
|
|
556
|
-
requests,
|
|
557
|
-
startTime,
|
|
558
|
-
totalDurationMs: Math.round(endTime - startTime),
|
|
559
|
-
hasErrors: requests.some((r) => r.statusCode >= 400),
|
|
560
|
-
warnings: detectWarnings(rawRequests),
|
|
561
|
-
sourcePage,
|
|
562
|
-
redundancyPct
|
|
563
|
-
};
|
|
640
|
+
return flat;
|
|
564
641
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (req.sourcePage) {
|
|
569
|
-
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
570
|
-
}
|
|
642
|
+
var RequestStore = class {
|
|
643
|
+
constructor(maxEntries = MAX_REQUEST_ENTRIES) {
|
|
644
|
+
this.maxEntries = maxEntries;
|
|
571
645
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
646
|
+
requests = [];
|
|
647
|
+
listeners = [];
|
|
648
|
+
capture(input) {
|
|
649
|
+
const url = input.url;
|
|
650
|
+
const path = url.split("?")[0];
|
|
651
|
+
let requestBodyStr = null;
|
|
652
|
+
if (input.requestBody && input.requestBody.length > 0) {
|
|
653
|
+
requestBodyStr = input.requestBody.subarray(0, input.config.maxBodyCapture).toString("utf-8");
|
|
654
|
+
}
|
|
655
|
+
let responseBodyStr = null;
|
|
656
|
+
if (input.responseBody && input.responseBody.length > 0) {
|
|
657
|
+
const ct = input.responseContentType;
|
|
658
|
+
if (ct.includes("json") || ct.includes("text") || ct.includes("html")) {
|
|
659
|
+
responseBodyStr = input.responseBody.subarray(0, input.config.maxBodyCapture).toString("utf-8");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const entry = {
|
|
663
|
+
id: input.requestId,
|
|
664
|
+
method: input.method,
|
|
665
|
+
url,
|
|
666
|
+
path,
|
|
667
|
+
headers: flattenHeaders(input.requestHeaders),
|
|
668
|
+
requestBody: requestBodyStr,
|
|
669
|
+
statusCode: input.statusCode,
|
|
670
|
+
responseHeaders: flattenHeaders(input.responseHeaders),
|
|
671
|
+
responseBody: responseBodyStr,
|
|
672
|
+
startedAt: input.startTime,
|
|
673
|
+
durationMs: Math.round(performance.now() - input.startTime),
|
|
674
|
+
responseSize: input.responseBody?.length ?? 0,
|
|
675
|
+
isStatic: isStaticPath(path)
|
|
676
|
+
};
|
|
677
|
+
this.requests.push(entry);
|
|
678
|
+
if (this.requests.length > this.maxEntries) {
|
|
679
|
+
this.requests.shift();
|
|
578
680
|
}
|
|
681
|
+
for (const fn of this.listeners) {
|
|
682
|
+
fn(entry);
|
|
683
|
+
}
|
|
684
|
+
return entry;
|
|
579
685
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
function deriveFlowLabel(requests, sourcePage) {
|
|
583
|
-
const trigger = requests.find((r) => r.category === "api-call") ?? requests.find((r) => r.category === "server-action") ?? requests.find((r) => r.category === "page-load") ?? requests.find((r) => r.category === "navigation") ?? requests.find((r) => r.category === "data-fetch") ?? requests[0];
|
|
584
|
-
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
585
|
-
const pageName = prettifyPageName(trigger.path.split("?")[0]);
|
|
586
|
-
return `${pageName} Page`;
|
|
686
|
+
getAll() {
|
|
687
|
+
return this.requests;
|
|
587
688
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const parts = effectivePath.replace(/^\/api\//, "").split("/");
|
|
591
|
-
const endpointName = parts.length <= 2 ? parts.join("/") : parts.map((p) => p.length > 12 ? "..." : p).join("/");
|
|
592
|
-
const action = deriveActionVerb(trigger.method, endpointName);
|
|
593
|
-
const name = prettifyEndpoint(endpointName);
|
|
594
|
-
return `${action} ${capitalize(name)}`;
|
|
689
|
+
clear() {
|
|
690
|
+
this.requests.length = 0;
|
|
595
691
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
return capitalize(name);
|
|
692
|
+
onRequest(fn) {
|
|
693
|
+
this.listeners.push(fn);
|
|
599
694
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
return `${pageName} Page`;
|
|
604
|
-
}
|
|
605
|
-
return trigger.label;
|
|
695
|
+
offRequest(fn) {
|
|
696
|
+
const idx = this.listeners.indexOf(fn);
|
|
697
|
+
if (idx !== -1) this.listeners.splice(idx, 1);
|
|
606
698
|
}
|
|
607
|
-
|
|
608
|
-
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// src/store/request-log.ts
|
|
702
|
+
var defaultStore = new RequestStore();
|
|
703
|
+
var getRequests = () => defaultStore.getAll();
|
|
704
|
+
var onRequest = (fn) => defaultStore.onRequest(fn);
|
|
705
|
+
var offRequest = (fn) => defaultStore.offRequest(fn);
|
|
609
706
|
|
|
610
707
|
// src/store/telemetry-store.ts
|
|
611
|
-
import { randomUUID
|
|
708
|
+
import { randomUUID } from "crypto";
|
|
612
709
|
var TelemetryStore = class {
|
|
613
710
|
constructor(maxEntries = MAX_TELEMETRY_ENTRIES) {
|
|
614
711
|
this.maxEntries = maxEntries;
|
|
@@ -616,7 +713,7 @@ var TelemetryStore = class {
|
|
|
616
713
|
entries = [];
|
|
617
714
|
listeners = [];
|
|
618
715
|
add(data) {
|
|
619
|
-
const entry = { id:
|
|
716
|
+
const entry = { id: randomUUID(), ...data };
|
|
620
717
|
this.entries.push(entry);
|
|
621
718
|
if (this.entries.length > this.maxEntries) this.entries.shift();
|
|
622
719
|
for (const fn of this.listeners) fn(entry);
|
|
@@ -661,7 +758,7 @@ var QueryStore = class extends TelemetryStore {
|
|
|
661
758
|
var defaultQueryStore = new QueryStore();
|
|
662
759
|
|
|
663
760
|
// src/store/metrics/metrics-store.ts
|
|
664
|
-
import { randomUUID as
|
|
761
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
665
762
|
|
|
666
763
|
// src/store/metrics/persistence.ts
|
|
667
764
|
import {
|
|
@@ -673,641 +770,357 @@ import {
|
|
|
673
770
|
} from "fs";
|
|
674
771
|
import { resolve as resolve2 } from "path";
|
|
675
772
|
|
|
676
|
-
// src/
|
|
677
|
-
import {
|
|
678
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
679
|
-
import { resolve } from "path";
|
|
680
|
-
async function fileExists(path) {
|
|
681
|
-
try {
|
|
682
|
-
await access(path);
|
|
683
|
-
return true;
|
|
684
|
-
} catch {
|
|
685
|
-
return false;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// src/dashboard/client/constants/thresholds.ts
|
|
690
|
-
var HEALTH_FAST_MS = 100;
|
|
691
|
-
var HEALTH_GOOD_MS = 300;
|
|
692
|
-
var HEALTH_OK_MS = 800;
|
|
693
|
-
var HEALTH_SLOW_MS = 2e3;
|
|
694
|
-
|
|
695
|
-
// src/dashboard/client/constants/display.ts
|
|
696
|
-
var HEALTH_GRADES = `[
|
|
697
|
-
{ max: ${HEALTH_FAST_MS}, label: 'Fast', color: 'var(--green)', bg: 'rgba(22,163,74,0.08)', border: 'rgba(22,163,74,0.2)' },
|
|
698
|
-
{ max: ${HEALTH_GOOD_MS}, label: 'Good', color: 'var(--green)', bg: 'rgba(22,163,74,0.06)', border: 'rgba(22,163,74,0.15)' },
|
|
699
|
-
{ max: ${HEALTH_OK_MS}, label: 'OK', color: 'var(--amber)', bg: 'rgba(217,119,6,0.06)', border: 'rgba(217,119,6,0.15)' },
|
|
700
|
-
{ max: ${HEALTH_SLOW_MS}, label: 'Slow', color: 'var(--red)', bg: 'rgba(220,38,38,0.06)', border: 'rgba(220,38,38,0.15)' },
|
|
701
|
-
{ max: Infinity, label: 'Critical', color: 'var(--red)', bg: 'rgba(220,38,38,0.08)', border: 'rgba(220,38,38,0.2)' }
|
|
702
|
-
]`;
|
|
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
|
-
|
|
715
|
-
// src/dashboard/router.ts
|
|
716
|
-
function isDashboardRequest(url) {
|
|
717
|
-
return url === DASHBOARD_PREFIX || url.startsWith(DASHBOARD_PREFIX + "/");
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// src/proxy/server.ts
|
|
721
|
-
function createProxyServer(config, handleDashboard) {
|
|
722
|
-
const server = createServer((clientReq, clientRes) => {
|
|
723
|
-
if (isDashboardRequest(clientReq.url ?? "")) {
|
|
724
|
-
handleDashboard(clientReq, clientRes, config);
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
proxyRequest(clientReq, clientRes, config);
|
|
728
|
-
});
|
|
729
|
-
server.on("upgrade", (req, socket, head) => {
|
|
730
|
-
handleUpgrade(req, socket, head, config.targetPort);
|
|
731
|
-
});
|
|
732
|
-
return server;
|
|
733
|
-
}
|
|
773
|
+
// src/analysis/group.ts
|
|
774
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
734
775
|
|
|
735
|
-
// src/
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
{ name: "nuxt", dep: "nuxt", devCmd: "nuxt dev", bin: "nuxt", defaultPort: 3e3, devArgs: ["dev", "--port"] },
|
|
742
|
-
{ name: "vite", dep: "vite", devCmd: "vite", bin: "vite", defaultPort: 5173, devArgs: ["--port"] },
|
|
743
|
-
{ name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
|
|
744
|
-
];
|
|
745
|
-
async function detectProject(rootDir) {
|
|
746
|
-
const pkgPath = join2(rootDir, "package.json");
|
|
747
|
-
const raw = await readFile2(pkgPath, "utf-8");
|
|
748
|
-
const pkg = JSON.parse(raw);
|
|
749
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
750
|
-
let framework = "unknown";
|
|
751
|
-
let devCommand = "";
|
|
752
|
-
let devBin = "";
|
|
753
|
-
let defaultPort = 3e3;
|
|
754
|
-
for (const f of FRAMEWORKS) {
|
|
755
|
-
if (allDeps[f.dep]) {
|
|
756
|
-
framework = f.name;
|
|
757
|
-
devCommand = f.devCmd;
|
|
758
|
-
devBin = join2(rootDir, "node_modules", ".bin", f.bin);
|
|
759
|
-
defaultPort = f.defaultPort;
|
|
760
|
-
break;
|
|
761
|
-
}
|
|
776
|
+
// src/analysis/categorize.ts
|
|
777
|
+
function detectCategory(req) {
|
|
778
|
+
const { method, url, statusCode, responseHeaders } = req;
|
|
779
|
+
if (req.isStatic) return "static";
|
|
780
|
+
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
781
|
+
return "auth-handshake";
|
|
762
782
|
}
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
async function detectPackageManager(rootDir) {
|
|
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";
|
|
772
|
-
return "unknown";
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// src/instrument/adapter-registry.ts
|
|
776
|
-
var AdapterRegistry = class {
|
|
777
|
-
adapters = [];
|
|
778
|
-
active = [];
|
|
779
|
-
register(adapter) {
|
|
780
|
-
this.adapters.push(adapter);
|
|
783
|
+
const effectivePath = getEffectivePath(req);
|
|
784
|
+
if (/^\/api\/auth/i.test(effectivePath) || /^\/(api\/)?clerk/i.test(effectivePath)) {
|
|
785
|
+
return "auth-check";
|
|
781
786
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
787
|
+
if (method === "POST" && !effectivePath.startsWith("/api/")) {
|
|
788
|
+
return "server-action";
|
|
789
|
+
}
|
|
790
|
+
if (effectivePath.startsWith("/api/") && method !== "GET" && method !== "HEAD") {
|
|
791
|
+
return "api-call";
|
|
792
|
+
}
|
|
793
|
+
if (effectivePath.startsWith("/api/") && method === "GET") {
|
|
794
|
+
return "data-fetch";
|
|
795
|
+
}
|
|
796
|
+
if (url.includes("_rsc=")) {
|
|
797
|
+
return "navigation";
|
|
798
|
+
}
|
|
799
|
+
if (responseHeaders["x-middleware-rewrite"]) {
|
|
800
|
+
return "middleware";
|
|
792
801
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
adapter.unpatch?.();
|
|
797
|
-
} catch {
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
this.active = [];
|
|
802
|
+
if (method === "GET") {
|
|
803
|
+
const ct = responseHeaders["content-type"] ?? "";
|
|
804
|
+
if (ct.includes("text/html")) return "page-load";
|
|
801
805
|
}
|
|
802
|
-
|
|
803
|
-
|
|
806
|
+
return "unknown";
|
|
807
|
+
}
|
|
808
|
+
function getEffectivePath(req) {
|
|
809
|
+
const rewrite = req.responseHeaders["x-middleware-rewrite"];
|
|
810
|
+
if (!rewrite) return req.path;
|
|
811
|
+
try {
|
|
812
|
+
const url = new URL(rewrite, "http://localhost");
|
|
813
|
+
return url.pathname;
|
|
814
|
+
} catch {
|
|
815
|
+
return rewrite.startsWith("/") ? rewrite : req.path;
|
|
804
816
|
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// src/analysis/rules/patterns.ts
|
|
808
|
-
var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
|
|
809
|
-
var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
|
|
810
|
-
var SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
|
|
811
|
-
var STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections/;
|
|
812
|
-
var DB_CONN_RE = /(postgres|mysql|mongodb|redis):\/\//;
|
|
813
|
-
var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i;
|
|
814
|
-
var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
|
|
815
|
-
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
|
|
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$/;
|
|
820
|
-
var RULE_HINTS = {
|
|
821
|
-
"exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
|
|
822
|
-
"token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
|
|
823
|
-
"stack-trace-leak": "Use a custom error handler that returns generic messages in production.",
|
|
824
|
-
"error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
|
|
825
|
-
"sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
|
|
826
|
-
"cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
|
|
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."
|
|
829
|
-
};
|
|
817
|
+
}
|
|
830
818
|
|
|
831
|
-
// src/analysis/
|
|
832
|
-
function
|
|
833
|
-
|
|
819
|
+
// src/analysis/label.ts
|
|
820
|
+
function extractSourcePage(req) {
|
|
821
|
+
const referer = req.headers["referer"] ?? req.headers["Referer"];
|
|
822
|
+
if (!referer) return void 0;
|
|
834
823
|
try {
|
|
835
|
-
|
|
824
|
+
const url = new URL(referer);
|
|
825
|
+
return url.pathname;
|
|
836
826
|
} catch {
|
|
837
|
-
return
|
|
827
|
+
return void 0;
|
|
838
828
|
}
|
|
839
829
|
}
|
|
840
|
-
function
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
found.push(...findSecretKeys(obj[i], prefix));
|
|
846
|
-
}
|
|
847
|
-
return found;
|
|
848
|
-
}
|
|
849
|
-
for (const k of Object.keys(obj)) {
|
|
850
|
-
const val = obj[k];
|
|
851
|
-
if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= 8 && !MASKED_RE.test(val)) {
|
|
852
|
-
found.push(k);
|
|
853
|
-
}
|
|
854
|
-
if (typeof val === "object" && val !== null) {
|
|
855
|
-
found.push(...findSecretKeys(val, prefix + k + "."));
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
return found;
|
|
830
|
+
function labelRequest(req) {
|
|
831
|
+
const category = detectCategory(req);
|
|
832
|
+
const label = generateHumanLabel(req, category);
|
|
833
|
+
const sourcePage = extractSourcePage(req);
|
|
834
|
+
return { ...req, category, label, sourcePage };
|
|
859
835
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
const keys = findSecretKeys(parsed, "");
|
|
873
|
-
if (keys.length === 0) continue;
|
|
874
|
-
const ep = `${r.method} ${r.path}`;
|
|
875
|
-
const dedupKey = `${ep}:${keys.sort().join(",")}`;
|
|
876
|
-
const existing = seen.get(dedupKey);
|
|
877
|
-
if (existing) {
|
|
878
|
-
existing.count++;
|
|
879
|
-
continue;
|
|
880
|
-
}
|
|
881
|
-
const finding = {
|
|
882
|
-
severity: "critical",
|
|
883
|
-
rule: "exposed-secret",
|
|
884
|
-
title: "Exposed Secret in Response",
|
|
885
|
-
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
886
|
-
hint: this.hint,
|
|
887
|
-
endpoint: ep,
|
|
888
|
-
count: 1
|
|
889
|
-
};
|
|
890
|
-
seen.set(dedupKey, finding);
|
|
891
|
-
findings.push(finding);
|
|
892
|
-
}
|
|
893
|
-
return findings;
|
|
894
|
-
}
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
// src/analysis/rules/token-in-url.ts
|
|
898
|
-
var tokenInUrlRule = {
|
|
899
|
-
id: "token-in-url",
|
|
900
|
-
severity: "critical",
|
|
901
|
-
name: "Auth Token in URL",
|
|
902
|
-
hint: RULE_HINTS["token-in-url"],
|
|
903
|
-
check(ctx) {
|
|
904
|
-
const findings = [];
|
|
905
|
-
const seen = /* @__PURE__ */ new Map();
|
|
906
|
-
for (const r of ctx.requests) {
|
|
907
|
-
const qIdx = r.url.indexOf("?");
|
|
908
|
-
if (qIdx === -1) continue;
|
|
909
|
-
const params = r.url.substring(qIdx + 1).split("&");
|
|
910
|
-
const flagged = [];
|
|
911
|
-
for (const param of params) {
|
|
912
|
-
const [name, ...rest] = param.split("=");
|
|
913
|
-
const val = rest.join("=");
|
|
914
|
-
if (SAFE_PARAMS.test(name)) continue;
|
|
915
|
-
if (TOKEN_PARAMS.test(name) && val && val.length > 0) {
|
|
916
|
-
flagged.push(name);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
if (flagged.length === 0) continue;
|
|
920
|
-
const ep = `${r.method} ${r.path}`;
|
|
921
|
-
const dedupKey = `${ep}:${flagged.sort().join(",")}`;
|
|
922
|
-
const existing = seen.get(dedupKey);
|
|
923
|
-
if (existing) {
|
|
924
|
-
existing.count++;
|
|
925
|
-
continue;
|
|
926
|
-
}
|
|
927
|
-
const finding = {
|
|
928
|
-
severity: "critical",
|
|
929
|
-
rule: "token-in-url",
|
|
930
|
-
title: "Auth Token in URL",
|
|
931
|
-
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
932
|
-
hint: this.hint,
|
|
933
|
-
endpoint: ep,
|
|
934
|
-
count: 1
|
|
935
|
-
};
|
|
936
|
-
seen.set(dedupKey, finding);
|
|
937
|
-
findings.push(finding);
|
|
836
|
+
function generateHumanLabel(req, category) {
|
|
837
|
+
const effectivePath = getEffectivePath(req);
|
|
838
|
+
const endpointName = getEndpointName(effectivePath);
|
|
839
|
+
const failed = req.statusCode >= 400;
|
|
840
|
+
switch (category) {
|
|
841
|
+
case "auth-handshake":
|
|
842
|
+
return "Auth handshake";
|
|
843
|
+
case "auth-check":
|
|
844
|
+
return failed ? "Auth check failed" : "Checked auth";
|
|
845
|
+
case "middleware": {
|
|
846
|
+
const rewritePath = effectivePath !== req.path ? effectivePath : "";
|
|
847
|
+
return rewritePath ? `Redirected to ${rewritePath}` : "Middleware";
|
|
938
848
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
};
|
|
942
|
-
|
|
943
|
-
// src/analysis/rules/stack-trace-leak.ts
|
|
944
|
-
var stackTraceLeakRule = {
|
|
945
|
-
id: "stack-trace-leak",
|
|
946
|
-
severity: "critical",
|
|
947
|
-
name: "Stack Trace Leaked to Client",
|
|
948
|
-
hint: RULE_HINTS["stack-trace-leak"],
|
|
949
|
-
check(ctx) {
|
|
950
|
-
const findings = [];
|
|
951
|
-
const seen = /* @__PURE__ */ new Map();
|
|
952
|
-
for (const r of ctx.requests) {
|
|
953
|
-
if (!r.responseBody) continue;
|
|
954
|
-
if (!STACK_TRACE_RE.test(r.responseBody)) continue;
|
|
955
|
-
const ep = `${r.method} ${r.path}`;
|
|
956
|
-
const existing = seen.get(ep);
|
|
957
|
-
if (existing) {
|
|
958
|
-
existing.count++;
|
|
959
|
-
continue;
|
|
960
|
-
}
|
|
961
|
-
const finding = {
|
|
962
|
-
severity: "critical",
|
|
963
|
-
rule: "stack-trace-leak",
|
|
964
|
-
title: "Stack Trace Leaked to Client",
|
|
965
|
-
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
966
|
-
hint: this.hint,
|
|
967
|
-
endpoint: ep,
|
|
968
|
-
count: 1
|
|
969
|
-
};
|
|
970
|
-
seen.set(ep, finding);
|
|
971
|
-
findings.push(finding);
|
|
849
|
+
case "server-action": {
|
|
850
|
+
const name = prettifyEndpoint(req.path);
|
|
851
|
+
return failed ? `${name} failed` : name;
|
|
972
852
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
var CRITICAL_PATTERNS = [
|
|
979
|
-
{ re: DB_CONN_RE, label: "database connection string" },
|
|
980
|
-
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
981
|
-
{ re: SECRET_VAL_RE, label: "secret value" }
|
|
982
|
-
];
|
|
983
|
-
var errorInfoLeakRule = {
|
|
984
|
-
id: "error-info-leak",
|
|
985
|
-
severity: "critical",
|
|
986
|
-
name: "Sensitive Data in Error Response",
|
|
987
|
-
hint: RULE_HINTS["error-info-leak"],
|
|
988
|
-
check(ctx) {
|
|
989
|
-
const findings = [];
|
|
990
|
-
const seen = /* @__PURE__ */ new Map();
|
|
991
|
-
for (const r of ctx.requests) {
|
|
992
|
-
if (r.statusCode < 400) continue;
|
|
993
|
-
if (!r.responseBody) continue;
|
|
994
|
-
if (r.responseHeaders["x-nextjs-error"] || r.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
995
|
-
const ep = `${r.method} ${r.path}`;
|
|
996
|
-
for (const p of CRITICAL_PATTERNS) {
|
|
997
|
-
if (!p.re.test(r.responseBody)) continue;
|
|
998
|
-
const dedupKey = `${ep}:${p.label}`;
|
|
999
|
-
const existing = seen.get(dedupKey);
|
|
1000
|
-
if (existing) {
|
|
1001
|
-
existing.count++;
|
|
1002
|
-
continue;
|
|
1003
|
-
}
|
|
1004
|
-
const finding = {
|
|
1005
|
-
severity: "critical",
|
|
1006
|
-
rule: "error-info-leak",
|
|
1007
|
-
title: "Sensitive Data in Error Response",
|
|
1008
|
-
desc: `${ep} \u2014 error response exposes ${p.label}`,
|
|
1009
|
-
hint: this.hint,
|
|
1010
|
-
endpoint: ep,
|
|
1011
|
-
count: 1
|
|
1012
|
-
};
|
|
1013
|
-
seen.set(dedupKey, finding);
|
|
1014
|
-
findings.push(finding);
|
|
1015
|
-
}
|
|
853
|
+
case "api-call": {
|
|
854
|
+
const action = deriveActionVerb(req.method, endpointName);
|
|
855
|
+
const name = prettifyEndpoint(endpointName);
|
|
856
|
+
if (failed) return `${action} ${name} failed`;
|
|
857
|
+
return `${action} ${name}`;
|
|
1016
858
|
}
|
|
1017
|
-
|
|
859
|
+
case "data-fetch": {
|
|
860
|
+
const name = prettifyEndpoint(endpointName);
|
|
861
|
+
if (failed) return `Failed to load ${name}`;
|
|
862
|
+
return `Loaded ${name}`;
|
|
863
|
+
}
|
|
864
|
+
case "page-load":
|
|
865
|
+
return failed ? "Page error" : "Loaded page";
|
|
866
|
+
case "navigation":
|
|
867
|
+
return "Navigated";
|
|
868
|
+
case "static":
|
|
869
|
+
return `Static: ${req.path.split("/").pop() ?? req.path}`;
|
|
870
|
+
default:
|
|
871
|
+
return failed ? `${req.method} ${req.path} failed` : `${req.method} ${req.path}`;
|
|
1018
872
|
}
|
|
1019
|
-
};
|
|
1020
|
-
|
|
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
873
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
rule: "insecure-cookie",
|
|
1058
|
-
title: "Insecure Cookie",
|
|
1059
|
-
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
1060
|
-
hint: this.hint,
|
|
1061
|
-
endpoint: cookieName,
|
|
1062
|
-
count: 1
|
|
1063
|
-
};
|
|
1064
|
-
seen.set(dedupKey, finding);
|
|
1065
|
-
findings.push(finding);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
return findings;
|
|
874
|
+
function prettifyEndpoint(name) {
|
|
875
|
+
const cleaned = name.replace(/^\/api\//, "").replace(/\//g, " ").replace(/\.\.\./g, "").trim();
|
|
876
|
+
if (!cleaned) return "data";
|
|
877
|
+
return cleaned.split(" ").map((word) => {
|
|
878
|
+
if (word.endsWith("ses") || word.endsWith("us") || word.endsWith("ss"))
|
|
879
|
+
return word;
|
|
880
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
881
|
+
if (word.endsWith("s") && word.length > 3) return word.slice(0, -1);
|
|
882
|
+
return word;
|
|
883
|
+
}).join(" ");
|
|
884
|
+
}
|
|
885
|
+
function deriveActionVerb(method, endpointName) {
|
|
886
|
+
const lower = endpointName.toLowerCase();
|
|
887
|
+
const VERB_PATTERNS = [
|
|
888
|
+
[/enhance/, "Enhanced"],
|
|
889
|
+
[/generate/, "Generated"],
|
|
890
|
+
[/create/, "Created"],
|
|
891
|
+
[/update/, "Updated"],
|
|
892
|
+
[/delete|remove/, "Deleted"],
|
|
893
|
+
[/send/, "Sent"],
|
|
894
|
+
[/upload/, "Uploaded"],
|
|
895
|
+
[/save/, "Saved"],
|
|
896
|
+
[/submit/, "Submitted"],
|
|
897
|
+
[/login|signin/, "Logged in"],
|
|
898
|
+
[/logout|signout/, "Logged out"],
|
|
899
|
+
[/register|signup/, "Registered"]
|
|
900
|
+
];
|
|
901
|
+
for (const [pattern, verb] of VERB_PATTERNS) {
|
|
902
|
+
if (pattern.test(lower)) return verb;
|
|
1069
903
|
}
|
|
1070
|
-
|
|
904
|
+
switch (method) {
|
|
905
|
+
case "POST":
|
|
906
|
+
return "Created";
|
|
907
|
+
case "PUT":
|
|
908
|
+
case "PATCH":
|
|
909
|
+
return "Updated";
|
|
910
|
+
case "DELETE":
|
|
911
|
+
return "Deleted";
|
|
912
|
+
default:
|
|
913
|
+
return "Called";
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function getEndpointName(path) {
|
|
917
|
+
const parts = path.replace(/^\/api\//, "").split("/");
|
|
918
|
+
if (parts.length <= 2) return parts.join("/");
|
|
919
|
+
return parts.map((p) => p.length > ENDPOINT_TRUNCATE_LENGTH ? "..." : p).join("/");
|
|
920
|
+
}
|
|
921
|
+
function prettifyPageName(path) {
|
|
922
|
+
const clean = path.replace(/^\//, "").replace(/\/$/, "");
|
|
923
|
+
if (!clean) return "Home";
|
|
924
|
+
return clean.split("/").map((s) => capitalize(s.replace(/[-_]/g, " "))).join(" ");
|
|
925
|
+
}
|
|
926
|
+
function capitalize(s) {
|
|
927
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
928
|
+
}
|
|
1071
929
|
|
|
1072
|
-
// src/analysis/
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
930
|
+
// src/analysis/transforms.ts
|
|
931
|
+
function markDuplicates(requests) {
|
|
932
|
+
const counts = /* @__PURE__ */ new Map();
|
|
933
|
+
for (const req of requests) {
|
|
934
|
+
if (req.category !== "data-fetch" && req.category !== "auth-check")
|
|
935
|
+
continue;
|
|
936
|
+
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
937
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
938
|
+
}
|
|
939
|
+
const isStrictMode = counts.size > 0 && [...counts.values()].every((c) => c === 2);
|
|
940
|
+
const seen = /* @__PURE__ */ new Set();
|
|
941
|
+
for (const req of requests) {
|
|
942
|
+
if (req.category !== "data-fetch" && req.category !== "auth-check")
|
|
943
|
+
continue;
|
|
944
|
+
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
945
|
+
if (seen.has(key)) {
|
|
946
|
+
if (isStrictMode) {
|
|
947
|
+
req.isStrictModeDupe = true;
|
|
948
|
+
} else {
|
|
949
|
+
req.isDuplicate = true;
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
seen.add(key);
|
|
1084
953
|
}
|
|
1085
|
-
if (count === 0) return [];
|
|
1086
|
-
return [{
|
|
1087
|
-
severity: "warning",
|
|
1088
|
-
rule: "sensitive-logs",
|
|
1089
|
-
title: "Sensitive Data in Logs",
|
|
1090
|
-
desc: `Console output contains secret/token values \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1091
|
-
hint: this.hint,
|
|
1092
|
-
endpoint: "console",
|
|
1093
|
-
count
|
|
1094
|
-
}];
|
|
1095
954
|
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}
|
|
955
|
+
}
|
|
956
|
+
function collapsePolling(requests) {
|
|
957
|
+
const result = [];
|
|
958
|
+
let i = 0;
|
|
959
|
+
while (i < requests.length) {
|
|
960
|
+
const current = requests[i];
|
|
961
|
+
const currentEffective = getEffectivePath(current).split("?")[0];
|
|
962
|
+
if (current.method === "GET" && current.category === "data-fetch") {
|
|
963
|
+
let j = i + 1;
|
|
964
|
+
while (j < requests.length && requests[j].method === "GET" && getEffectivePath(requests[j]).split("?")[0] === currentEffective) {
|
|
965
|
+
j++;
|
|
966
|
+
}
|
|
967
|
+
const count = j - i;
|
|
968
|
+
if (count >= MIN_POLLING_SEQUENCE) {
|
|
969
|
+
const last = requests[j - 1];
|
|
970
|
+
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
971
|
+
const endpointName = prettifyEndpoint(currentEffective);
|
|
972
|
+
result.push({
|
|
973
|
+
...current,
|
|
974
|
+
category: "polling",
|
|
975
|
+
label: `Polling ${endpointName} (${count}x, ${formatDurationLabel(pollingDuration)})`,
|
|
976
|
+
pollingCount: count,
|
|
977
|
+
pollingDurationMs: pollingDuration,
|
|
978
|
+
isDuplicate: false
|
|
979
|
+
});
|
|
980
|
+
i = j;
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
1124
983
|
}
|
|
1125
|
-
|
|
984
|
+
result.push(current);
|
|
985
|
+
i++;
|
|
1126
986
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
function
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
987
|
+
return result;
|
|
988
|
+
}
|
|
989
|
+
function formatDurationLabel(ms) {
|
|
990
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
991
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
992
|
+
}
|
|
993
|
+
function detectWarnings(requests) {
|
|
994
|
+
const warnings = [];
|
|
995
|
+
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
996
|
+
if (duplicateCount > 0) {
|
|
997
|
+
const unique = new Set(
|
|
998
|
+
requests.filter((r) => r.isDuplicate).map((r) => `${r.method} ${getEffectivePath(r).split("?")[0]}`)
|
|
999
|
+
);
|
|
1000
|
+
const endpoints = unique.size;
|
|
1001
|
+
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
1002
|
+
const key = `${r.method} ${getEffectivePath(r).split("?")[0]}`;
|
|
1003
|
+
const first = requests.find(
|
|
1004
|
+
(o) => !o.isDuplicate && `${o.method} ${getEffectivePath(o).split("?")[0]}` === key
|
|
1005
|
+
);
|
|
1006
|
+
return first && first.responseBody === r.responseBody;
|
|
1007
|
+
});
|
|
1008
|
+
const suffix = sameData ? " \u2014 same data loaded twice" : "";
|
|
1009
|
+
warnings.push(
|
|
1010
|
+
`${duplicateCount} request${duplicateCount > 1 ? "s" : ""} duplicated across ${endpoints} endpoint${endpoints > 1 ? "s" : ""}${suffix}`
|
|
1011
|
+
);
|
|
1139
1012
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
for (let i = 0; i < Math.min(obj.length, 10); i++) {
|
|
1146
|
-
emails.push(...findEmails(obj[i]));
|
|
1147
|
-
}
|
|
1148
|
-
return emails;
|
|
1013
|
+
const slowRequests = requests.filter(
|
|
1014
|
+
(r) => r.durationMs > SLOW_REQUEST_THRESHOLD_MS && r.category !== "polling"
|
|
1015
|
+
);
|
|
1016
|
+
for (const req of slowRequests) {
|
|
1017
|
+
warnings.push(`${req.label} took ${(req.durationMs / 1e3).toFixed(1)}s`);
|
|
1149
1018
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
} else if (typeof v === "object" && v !== null) {
|
|
1154
|
-
emails.push(...findEmails(v));
|
|
1155
|
-
}
|
|
1019
|
+
const errors = requests.filter((r) => r.statusCode >= 500);
|
|
1020
|
+
for (const req of errors) {
|
|
1021
|
+
warnings.push(`${req.label} \u2014 server error (${req.statusCode})`);
|
|
1156
1022
|
}
|
|
1157
|
-
return
|
|
1023
|
+
return warnings;
|
|
1158
1024
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1025
|
+
|
|
1026
|
+
// src/analysis/group.ts
|
|
1027
|
+
function groupRequestsIntoFlows(requests) {
|
|
1028
|
+
if (requests.length === 0) return [];
|
|
1029
|
+
const flows = [];
|
|
1030
|
+
let currentRequests = [];
|
|
1031
|
+
let currentSourcePage;
|
|
1032
|
+
let lastEndTime = 0;
|
|
1033
|
+
for (const req of requests) {
|
|
1034
|
+
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
1035
|
+
const labeled = labelRequest(req);
|
|
1036
|
+
if (labeled.category === "static") continue;
|
|
1037
|
+
const sourcePage = labeled.sourcePage;
|
|
1038
|
+
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
1039
|
+
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1040
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1041
|
+
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
1042
|
+
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
1043
|
+
flows.push(buildFlow(currentRequests));
|
|
1044
|
+
currentRequests = [];
|
|
1045
|
+
}
|
|
1046
|
+
currentRequests.push(labeled);
|
|
1047
|
+
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
1048
|
+
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
1162
1049
|
}
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
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;
|
|
1050
|
+
if (currentRequests.length > 0) {
|
|
1051
|
+
flows.push(buildFlow(currentRequests));
|
|
1170
1052
|
}
|
|
1171
|
-
return
|
|
1053
|
+
return flows;
|
|
1172
1054
|
}
|
|
1173
|
-
function
|
|
1174
|
-
|
|
1175
|
-
const
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1055
|
+
function buildFlow(rawRequests) {
|
|
1056
|
+
markDuplicates(rawRequests);
|
|
1057
|
+
const requests = collapsePolling(rawRequests);
|
|
1058
|
+
const first = requests[0];
|
|
1059
|
+
const startTime = first.startedAt;
|
|
1060
|
+
const endTime = Math.max(
|
|
1061
|
+
...requests.map(
|
|
1062
|
+
(r) => r.pollingDurationMs ? r.startedAt + r.pollingDurationMs : r.startedAt + r.durationMs
|
|
1063
|
+
)
|
|
1064
|
+
);
|
|
1065
|
+
const duplicateCount = rawRequests.filter((r) => r.isDuplicate).length;
|
|
1066
|
+
const nonStaticCount = rawRequests.length;
|
|
1067
|
+
const redundancyPct = nonStaticCount > 0 ? Math.round(duplicateCount / nonStaticCount * 100) : 0;
|
|
1068
|
+
const sourcePage = getDominantSourcePage(rawRequests);
|
|
1069
|
+
return {
|
|
1070
|
+
id: randomUUID3(),
|
|
1071
|
+
label: deriveFlowLabel(requests, sourcePage),
|
|
1072
|
+
requests,
|
|
1073
|
+
startTime,
|
|
1074
|
+
totalDurationMs: Math.round(endTime - startTime),
|
|
1075
|
+
hasErrors: requests.some((r) => r.statusCode >= 400),
|
|
1076
|
+
warnings: detectWarnings(rawRequests),
|
|
1077
|
+
sourcePage,
|
|
1078
|
+
redundancyPct
|
|
1079
|
+
};
|
|
1194
1080
|
}
|
|
1195
|
-
function
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
}
|
|
1081
|
+
function getDominantSourcePage(requests) {
|
|
1082
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1083
|
+
for (const req of requests) {
|
|
1084
|
+
if (req.sourcePage) {
|
|
1085
|
+
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
1217
1086
|
}
|
|
1218
1087
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
}
|
|
1088
|
+
let best = "";
|
|
1089
|
+
let bestCount = 0;
|
|
1090
|
+
for (const [page, count] of counts) {
|
|
1091
|
+
if (count > bestCount) {
|
|
1092
|
+
best = page;
|
|
1093
|
+
bestCount = count;
|
|
1233
1094
|
}
|
|
1234
1095
|
}
|
|
1235
|
-
return
|
|
1096
|
+
return best || requests[0]?.path?.split("?")[0] || "/";
|
|
1236
1097
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
"
|
|
1240
|
-
|
|
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;
|
|
1098
|
+
function deriveFlowLabel(requests, sourcePage) {
|
|
1099
|
+
const trigger = requests.find((r) => r.category === "api-call") ?? requests.find((r) => r.category === "server-action") ?? requests.find((r) => r.category === "page-load") ?? requests.find((r) => r.category === "navigation") ?? requests.find((r) => r.category === "data-fetch") ?? requests[0];
|
|
1100
|
+
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
1101
|
+
const pageName = prettifyPageName(trigger.path.split("?")[0]);
|
|
1102
|
+
return `${pageName} Page`;
|
|
1277
1103
|
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1104
|
+
if (trigger.category === "api-call") {
|
|
1105
|
+
const effectivePath = getEffectivePath(trigger);
|
|
1106
|
+
const parts = effectivePath.replace(/^\/api\//, "").split("/");
|
|
1107
|
+
const endpointName = parts.length <= 2 ? parts.join("/") : parts.map((p) => p.length > 12 ? "..." : p).join("/");
|
|
1108
|
+
const action = deriveActionVerb(trigger.method, endpointName);
|
|
1109
|
+
const name = prettifyEndpoint(endpointName);
|
|
1110
|
+
return `${action} ${capitalize(name)}`;
|
|
1285
1111
|
}
|
|
1286
|
-
|
|
1287
|
-
const
|
|
1288
|
-
|
|
1289
|
-
try {
|
|
1290
|
-
findings.push(...rule.check(ctx));
|
|
1291
|
-
} catch {
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
return findings;
|
|
1112
|
+
if (trigger.category === "server-action") {
|
|
1113
|
+
const name = prettifyEndpoint(trigger.path);
|
|
1114
|
+
return capitalize(name);
|
|
1295
1115
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1116
|
+
if (trigger.category === "data-fetch" || trigger.category === "polling") {
|
|
1117
|
+
if (sourcePage && sourcePage !== "/") {
|
|
1118
|
+
const pageName = prettifyPageName(sourcePage);
|
|
1119
|
+
return `${pageName} Page`;
|
|
1120
|
+
}
|
|
1121
|
+
return trigger.label;
|
|
1298
1122
|
}
|
|
1299
|
-
|
|
1300
|
-
function createDefaultScanner() {
|
|
1301
|
-
const scanner = new SecurityScanner();
|
|
1302
|
-
scanner.register(exposedSecretRule);
|
|
1303
|
-
scanner.register(tokenInUrlRule);
|
|
1304
|
-
scanner.register(stackTraceLeakRule);
|
|
1305
|
-
scanner.register(errorInfoLeakRule);
|
|
1306
|
-
scanner.register(insecureCookieRule);
|
|
1307
|
-
scanner.register(sensitiveLogsRule);
|
|
1308
|
-
scanner.register(corsCredentialsRule);
|
|
1309
|
-
scanner.register(responsePiiLeakRule);
|
|
1310
|
-
return scanner;
|
|
1123
|
+
return trigger.label;
|
|
1311
1124
|
}
|
|
1312
1125
|
|
|
1313
1126
|
// src/instrument/adapters/normalize.ts
|
|
@@ -1805,7 +1618,7 @@ var AnalysisEngine = class {
|
|
|
1805
1618
|
};
|
|
1806
1619
|
|
|
1807
1620
|
// src/index.ts
|
|
1808
|
-
var VERSION = "0.
|
|
1621
|
+
var VERSION = "0.7.0";
|
|
1809
1622
|
export {
|
|
1810
1623
|
AdapterRegistry,
|
|
1811
1624
|
AnalysisEngine,
|
|
@@ -1813,6 +1626,5 @@ export {
|
|
|
1813
1626
|
VERSION,
|
|
1814
1627
|
computeInsights,
|
|
1815
1628
|
createDefaultScanner,
|
|
1816
|
-
createProxyServer,
|
|
1817
1629
|
detectProject
|
|
1818
1630
|
};
|