brakit 0.8.6 → 0.9.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 +22 -4
- package/dist/api.d.ts +87 -48
- package/dist/api.js +820 -788
- package/dist/bin/brakit.js +327 -419
- package/dist/dashboard-client.global.js +901 -0
- package/dist/dashboard.html +1215 -2215
- package/dist/mcp/server.js +7 -15
- package/dist/runtime/index.js +3364 -5700
- package/package.json +4 -2
package/dist/api.js
CHANGED
|
@@ -10,17 +10,50 @@ import { createHash } from "crypto";
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { resolve, join } from "path";
|
|
12
12
|
|
|
13
|
-
// src/constants/
|
|
13
|
+
// src/constants/config.ts
|
|
14
14
|
var ANALYSIS_DEBOUNCE_MS = 300;
|
|
15
15
|
var ISSUE_ID_HASH_LENGTH = 16;
|
|
16
16
|
var ISSUES_DATA_VERSION = 2;
|
|
17
17
|
var SECRET_SCAN_ARRAY_LIMIT = 5;
|
|
18
18
|
var PII_SCAN_ARRAY_LIMIT = 10;
|
|
19
19
|
var MIN_SECRET_VALUE_LENGTH = 8;
|
|
20
|
-
var FULL_RECORD_MIN_FIELDS =
|
|
20
|
+
var FULL_RECORD_MIN_FIELDS = 8;
|
|
21
21
|
var LIST_PII_MIN_ITEMS = 2;
|
|
22
22
|
var MAX_OBJECT_SCAN_DEPTH = 5;
|
|
23
23
|
var ISSUE_PRUNE_TTL_MS = 10 * 60 * 1e3;
|
|
24
|
+
var FLOW_GAP_MS = 5e3;
|
|
25
|
+
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
26
|
+
var MIN_POLLING_SEQUENCE = 3;
|
|
27
|
+
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
28
|
+
var N1_QUERY_THRESHOLD = 5;
|
|
29
|
+
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
30
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
31
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
32
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
33
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
34
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
35
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
36
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
37
|
+
var HIGH_ROW_COUNT = 100;
|
|
38
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
39
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
40
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
41
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
42
|
+
var REGRESSION_PCT_THRESHOLD = 50;
|
|
43
|
+
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
44
|
+
var REGRESSION_MIN_REQUESTS = 5;
|
|
45
|
+
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
46
|
+
var OVERFETCH_MANY_FIELDS = 12;
|
|
47
|
+
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
48
|
+
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
49
|
+
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
50
|
+
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
51
|
+
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
52
|
+
var STRICT_MODE_MAX_GAP_MS = 2e3;
|
|
53
|
+
var BASELINE_MIN_SESSIONS = 2;
|
|
54
|
+
var BASELINE_MIN_REQUESTS_PER_SESSION = 3;
|
|
55
|
+
var ISSUES_FILE = "issues.json";
|
|
56
|
+
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
24
57
|
|
|
25
58
|
// src/utils/log.ts
|
|
26
59
|
var PREFIX = "[brakit]";
|
|
@@ -42,7 +75,9 @@ function getErrorMessage(err) {
|
|
|
42
75
|
return String(err);
|
|
43
76
|
}
|
|
44
77
|
function validateIssuesData(parsed) {
|
|
45
|
-
if (parsed
|
|
78
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
79
|
+
const obj = parsed;
|
|
80
|
+
if (obj.version === ISSUES_DATA_VERSION && Array.isArray(obj.issues)) {
|
|
46
81
|
return parsed;
|
|
47
82
|
}
|
|
48
83
|
return null;
|
|
@@ -86,41 +121,6 @@ async function ensureGitignoreAsync(dir, entry) {
|
|
|
86
121
|
}
|
|
87
122
|
}
|
|
88
123
|
|
|
89
|
-
// src/constants/metrics.ts
|
|
90
|
-
var ISSUES_FILE = "issues.json";
|
|
91
|
-
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
92
|
-
|
|
93
|
-
// src/constants/thresholds.ts
|
|
94
|
-
var FLOW_GAP_MS = 5e3;
|
|
95
|
-
var SLOW_REQUEST_THRESHOLD_MS = 2e3;
|
|
96
|
-
var MIN_POLLING_SEQUENCE = 3;
|
|
97
|
-
var ENDPOINT_TRUNCATE_LENGTH = 12;
|
|
98
|
-
var N1_QUERY_THRESHOLD = 5;
|
|
99
|
-
var ERROR_RATE_THRESHOLD_PCT = 20;
|
|
100
|
-
var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
101
|
-
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
102
|
-
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
103
|
-
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
104
|
-
var CROSS_ENDPOINT_PCT = 50;
|
|
105
|
-
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
106
|
-
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
107
|
-
var LARGE_RESPONSE_BYTES = 51200;
|
|
108
|
-
var HIGH_ROW_COUNT = 100;
|
|
109
|
-
var OVERFETCH_MIN_REQUESTS = 2;
|
|
110
|
-
var OVERFETCH_MIN_FIELDS = 8;
|
|
111
|
-
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
112
|
-
var OVERFETCH_NULL_RATIO = 0.3;
|
|
113
|
-
var REGRESSION_PCT_THRESHOLD = 50;
|
|
114
|
-
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
115
|
-
var REGRESSION_MIN_REQUESTS = 5;
|
|
116
|
-
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
117
|
-
var OVERFETCH_MANY_FIELDS = 12;
|
|
118
|
-
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
119
|
-
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
120
|
-
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
121
|
-
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
122
|
-
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
123
|
-
|
|
124
124
|
// src/utils/atomic-writer.ts
|
|
125
125
|
import {
|
|
126
126
|
writeFileSync as writeFileSync2,
|
|
@@ -132,11 +132,10 @@ import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
|
|
|
132
132
|
var AtomicWriter = class {
|
|
133
133
|
constructor(opts) {
|
|
134
134
|
this.opts = opts;
|
|
135
|
+
this.writing = false;
|
|
136
|
+
this.pendingContent = null;
|
|
135
137
|
this.tmpPath = opts.filePath + ".tmp";
|
|
136
138
|
}
|
|
137
|
-
tmpPath;
|
|
138
|
-
writing = false;
|
|
139
|
-
pendingContent = null;
|
|
140
139
|
writeSync(content) {
|
|
141
140
|
try {
|
|
142
141
|
this.ensureDir();
|
|
@@ -198,6 +197,9 @@ function computeIssueId(issue) {
|
|
|
198
197
|
var IssueStore = class {
|
|
199
198
|
constructor(dataDir) {
|
|
200
199
|
this.dataDir = dataDir;
|
|
200
|
+
this.issues = /* @__PURE__ */ new Map();
|
|
201
|
+
this.flushTimer = null;
|
|
202
|
+
this.dirty = false;
|
|
201
203
|
this.issuesPath = resolve2(dataDir, ISSUES_FILE);
|
|
202
204
|
this.writer = new AtomicWriter({
|
|
203
205
|
dir: dataDir,
|
|
@@ -205,11 +207,6 @@ var IssueStore = class {
|
|
|
205
207
|
label: "issues"
|
|
206
208
|
});
|
|
207
209
|
}
|
|
208
|
-
issues = /* @__PURE__ */ new Map();
|
|
209
|
-
flushTimer = null;
|
|
210
|
-
dirty = false;
|
|
211
|
-
writer;
|
|
212
|
-
issuesPath;
|
|
213
210
|
start() {
|
|
214
211
|
this.loadAsync().catch((err) => brakitDebug(`IssueStore: async load failed: ${err}`));
|
|
215
212
|
this.flushTimer = setInterval(
|
|
@@ -429,8 +426,10 @@ function detectFrameworkFromDeps(allDeps) {
|
|
|
429
426
|
|
|
430
427
|
// src/instrument/adapter-registry.ts
|
|
431
428
|
var AdapterRegistry = class {
|
|
432
|
-
|
|
433
|
-
|
|
429
|
+
constructor() {
|
|
430
|
+
this.adapters = [];
|
|
431
|
+
this.active = [];
|
|
432
|
+
}
|
|
434
433
|
register(adapter) {
|
|
435
434
|
this.adapters.push(adapter);
|
|
436
435
|
}
|
|
@@ -468,11 +467,12 @@ function tryParseJson(body) {
|
|
|
468
467
|
return null;
|
|
469
468
|
}
|
|
470
469
|
}
|
|
470
|
+
var MAX_WRAPPER_KEYS = 3;
|
|
471
471
|
function unwrapResponse(parsed) {
|
|
472
472
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
473
473
|
const obj = parsed;
|
|
474
474
|
const keys = Object.keys(obj);
|
|
475
|
-
if (keys.length >
|
|
475
|
+
if (keys.length > MAX_WRAPPER_KEYS) return parsed;
|
|
476
476
|
let best = null;
|
|
477
477
|
let bestSize = 0;
|
|
478
478
|
for (const key of keys) {
|
|
@@ -504,6 +504,8 @@ var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
|
|
|
504
504
|
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
|
|
505
505
|
var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
|
|
506
506
|
var INTERNAL_ID_SUFFIX = /Id$|_id$/;
|
|
507
|
+
var SELF_SERVICE_PATH = /\/(?:me|account|profile|settings|self)(?=\/|\?|#|$)/i;
|
|
508
|
+
var SENSITIVE_FIELD_NAMES = /^(phone|phoneNumber|phone_number|ssn|socialSecurityNumber|social_security_number|dateOfBirth|date_of_birth|dob|address|streetAddress|street_address|creditCard|credit_card|cardNumber|card_number|bankAccount|bank_account|passport|passportNumber|passport_number|nationalId|national_id)$/i;
|
|
507
509
|
var SELECT_STAR_RE = /^SELECT\s+\*/i;
|
|
508
510
|
var SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
|
|
509
511
|
var RULE_HINTS = {
|
|
@@ -528,27 +530,87 @@ function isRedirect(code) {
|
|
|
528
530
|
return code >= 300 && code < 400;
|
|
529
531
|
}
|
|
530
532
|
|
|
531
|
-
// src/
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
533
|
+
// src/utils/collections.ts
|
|
534
|
+
function getOrCreate(map, key, create) {
|
|
535
|
+
let value = map.get(key);
|
|
536
|
+
if (value === void 0) {
|
|
537
|
+
value = create();
|
|
538
|
+
map.set(key, value);
|
|
539
|
+
}
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
542
|
+
function deduplicateFindings(items, extract) {
|
|
543
|
+
const seen = /* @__PURE__ */ new Map();
|
|
544
|
+
const findings = [];
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
const result = extract(item);
|
|
547
|
+
if (!result) continue;
|
|
548
|
+
const existing = seen.get(result.key);
|
|
549
|
+
if (existing) {
|
|
550
|
+
existing.count++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
seen.set(result.key, result.finding);
|
|
554
|
+
findings.push(result.finding);
|
|
555
|
+
}
|
|
556
|
+
return findings;
|
|
557
|
+
}
|
|
558
|
+
function groupBy(items, keyFn) {
|
|
559
|
+
const map = /* @__PURE__ */ new Map();
|
|
560
|
+
for (const item of items) {
|
|
561
|
+
const key = keyFn(item);
|
|
562
|
+
if (key == null) continue;
|
|
563
|
+
let arr = map.get(key);
|
|
564
|
+
if (!arr) {
|
|
565
|
+
arr = [];
|
|
566
|
+
map.set(key, arr);
|
|
539
567
|
}
|
|
540
|
-
|
|
568
|
+
arr.push(item);
|
|
541
569
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
570
|
+
return map;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/utils/object-scan.ts
|
|
574
|
+
var DEFAULTS = {
|
|
575
|
+
maxDepth: MAX_OBJECT_SCAN_DEPTH,
|
|
576
|
+
arrayLimit: SECRET_SCAN_ARRAY_LIMIT
|
|
577
|
+
};
|
|
578
|
+
function walkObject(obj, visitor, options) {
|
|
579
|
+
const opts = { ...DEFAULTS, ...options };
|
|
580
|
+
walk(obj, visitor, opts, 0);
|
|
581
|
+
}
|
|
582
|
+
function walk(obj, visitor, opts, depth) {
|
|
583
|
+
if (depth >= opts.maxDepth) return;
|
|
584
|
+
if (!obj || typeof obj !== "object") return;
|
|
585
|
+
if (Array.isArray(obj)) {
|
|
586
|
+
for (let i = 0; i < Math.min(obj.length, opts.arrayLimit); i++) {
|
|
587
|
+
walk(obj[i], visitor, opts, depth + 1);
|
|
546
588
|
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
for (const key of Object.keys(obj)) {
|
|
592
|
+
const val = obj[key];
|
|
593
|
+
visitor(key, val, depth);
|
|
547
594
|
if (typeof val === "object" && val !== null) {
|
|
548
|
-
|
|
595
|
+
walk(val, visitor, opts, depth + 1);
|
|
549
596
|
}
|
|
550
597
|
}
|
|
551
|
-
|
|
598
|
+
}
|
|
599
|
+
function collectFromObject(obj, match, options) {
|
|
600
|
+
const results = [];
|
|
601
|
+
walkObject(obj, (key, value) => {
|
|
602
|
+
const result = match(key, value);
|
|
603
|
+
if (result !== null) results.push(result);
|
|
604
|
+
}, options);
|
|
605
|
+
return results;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/analysis/rules/auth-rules.ts
|
|
609
|
+
function findSecretKeys(obj) {
|
|
610
|
+
return collectFromObject(
|
|
611
|
+
obj,
|
|
612
|
+
(key, val) => SECRET_KEYS.test(key) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val) ? key : null
|
|
613
|
+
);
|
|
552
614
|
}
|
|
553
615
|
var exposedSecretRule = {
|
|
554
616
|
id: "exposed-secret",
|
|
@@ -556,50 +618,39 @@ var exposedSecretRule = {
|
|
|
556
618
|
name: "Exposed Secret in Response",
|
|
557
619
|
hint: RULE_HINTS["exposed-secret"],
|
|
558
620
|
check(ctx) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (
|
|
563
|
-
const
|
|
564
|
-
if (
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
579
|
-
hint: this.hint,
|
|
580
|
-
endpoint: ep,
|
|
581
|
-
count: 1
|
|
621
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
622
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
623
|
+
const parsed = ctx.parsedBodies.response.get(request.id);
|
|
624
|
+
if (!parsed) return null;
|
|
625
|
+
const keys = findSecretKeys(parsed);
|
|
626
|
+
if (keys.length === 0) return null;
|
|
627
|
+
const ep = `${request.method} ${request.path}`;
|
|
628
|
+
return {
|
|
629
|
+
key: `${ep}:${keys.sort().join(",")}`,
|
|
630
|
+
finding: {
|
|
631
|
+
severity: "critical",
|
|
632
|
+
rule: "exposed-secret",
|
|
633
|
+
title: "Exposed Secret in Response",
|
|
634
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
635
|
+
hint: this.hint,
|
|
636
|
+
detail: `Exposed fields: ${keys.join(", ")}. ${keys.length} unmasked secret value${keys.length !== 1 ? "s" : ""} in response body.`,
|
|
637
|
+
endpoint: ep,
|
|
638
|
+
count: 1
|
|
639
|
+
}
|
|
582
640
|
};
|
|
583
|
-
|
|
584
|
-
findings.push(finding);
|
|
585
|
-
}
|
|
586
|
-
return findings;
|
|
641
|
+
});
|
|
587
642
|
}
|
|
588
643
|
};
|
|
589
|
-
|
|
590
|
-
// src/analysis/rules/token-in-url.ts
|
|
591
644
|
var tokenInUrlRule = {
|
|
592
645
|
id: "token-in-url",
|
|
593
646
|
severity: "critical",
|
|
594
647
|
name: "Auth Token in URL",
|
|
595
648
|
hint: RULE_HINTS["token-in-url"],
|
|
596
649
|
check(ctx) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
if (qIdx === -1) continue;
|
|
602
|
-
const params = r.url.substring(qIdx + 1).split("&");
|
|
650
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
651
|
+
const qIdx = request.url.indexOf("?");
|
|
652
|
+
if (qIdx === -1) return null;
|
|
653
|
+
const params = request.url.substring(qIdx + 1).split("&");
|
|
603
654
|
const flagged = [];
|
|
604
655
|
for (const param of params) {
|
|
605
656
|
const [name, ...rest] = param.split("=");
|
|
@@ -609,65 +660,128 @@ var tokenInUrlRule = {
|
|
|
609
660
|
flagged.push(name);
|
|
610
661
|
}
|
|
611
662
|
}
|
|
612
|
-
if (flagged.length === 0)
|
|
613
|
-
const ep = `${
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
663
|
+
if (flagged.length === 0) return null;
|
|
664
|
+
const ep = `${request.method} ${request.path}`;
|
|
665
|
+
return {
|
|
666
|
+
key: `${ep}:${flagged.sort().join(",")}`,
|
|
667
|
+
finding: {
|
|
668
|
+
severity: "critical",
|
|
669
|
+
rule: "token-in-url",
|
|
670
|
+
title: "Auth Token in URL",
|
|
671
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
672
|
+
hint: this.hint,
|
|
673
|
+
detail: `Parameters in URL: ${flagged.join(", ")}. Auth tokens in URLs are logged by proxies, browsers, and CDNs.`,
|
|
674
|
+
endpoint: ep,
|
|
675
|
+
count: 1
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
function isFrameworkResponse(request) {
|
|
682
|
+
if (isRedirect(request.statusCode)) return true;
|
|
683
|
+
if (request.path?.startsWith("/__")) return true;
|
|
684
|
+
if (request.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
var insecureCookieRule = {
|
|
688
|
+
id: "insecure-cookie",
|
|
689
|
+
severity: "warning",
|
|
690
|
+
name: "Insecure Cookie",
|
|
691
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
692
|
+
check(ctx) {
|
|
693
|
+
const cookieEntries = [];
|
|
694
|
+
for (const request of ctx.requests) {
|
|
695
|
+
if (!request.responseHeaders) continue;
|
|
696
|
+
if (isFrameworkResponse(request)) continue;
|
|
697
|
+
const setCookie = request.responseHeaders["set-cookie"];
|
|
698
|
+
if (!setCookie) continue;
|
|
699
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
700
|
+
for (const cookie of cookies) {
|
|
701
|
+
cookieEntries.push({ cookie });
|
|
619
702
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
703
|
+
}
|
|
704
|
+
return deduplicateFindings(cookieEntries, ({ cookie }) => {
|
|
705
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
706
|
+
const lower = cookie.toLowerCase();
|
|
707
|
+
const issues = [];
|
|
708
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
709
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
710
|
+
if (issues.length === 0) return null;
|
|
711
|
+
return {
|
|
712
|
+
key: `${cookieName}:${issues.join(",")}`,
|
|
713
|
+
finding: {
|
|
714
|
+
severity: "warning",
|
|
715
|
+
rule: "insecure-cookie",
|
|
716
|
+
title: "Insecure Cookie",
|
|
717
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
718
|
+
hint: this.hint,
|
|
719
|
+
detail: `Missing: ${issues.join(", ")}. ${issues.includes("HttpOnly") ? "Cookie accessible via JavaScript (XSS risk). " : ""}${issues.includes("SameSite") ? "Cookie sent on cross-site requests (CSRF risk)." : ""}`,
|
|
720
|
+
endpoint: cookieName,
|
|
721
|
+
count: 1
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
var corsCredentialsRule = {
|
|
728
|
+
id: "cors-credentials",
|
|
729
|
+
severity: "warning",
|
|
730
|
+
name: "CORS Credentials with Wildcard",
|
|
731
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
732
|
+
check(ctx) {
|
|
733
|
+
const findings = [];
|
|
734
|
+
const seen = /* @__PURE__ */ new Set();
|
|
735
|
+
for (const request of ctx.requests) {
|
|
736
|
+
if (!request.responseHeaders) continue;
|
|
737
|
+
const origin = request.responseHeaders["access-control-allow-origin"];
|
|
738
|
+
const creds = request.responseHeaders["access-control-allow-credentials"];
|
|
739
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
740
|
+
const ep = `${request.method} ${request.path}`;
|
|
741
|
+
if (seen.has(ep)) continue;
|
|
742
|
+
seen.add(ep);
|
|
743
|
+
findings.push({
|
|
744
|
+
severity: "warning",
|
|
745
|
+
rule: "cors-credentials",
|
|
746
|
+
title: "CORS Credentials with Wildcard",
|
|
747
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
625
748
|
hint: this.hint,
|
|
626
749
|
endpoint: ep,
|
|
627
750
|
count: 1
|
|
628
|
-
};
|
|
629
|
-
seen.set(dedupKey, finding);
|
|
630
|
-
findings.push(finding);
|
|
751
|
+
});
|
|
631
752
|
}
|
|
632
753
|
return findings;
|
|
633
754
|
}
|
|
634
755
|
};
|
|
635
756
|
|
|
636
|
-
// src/analysis/rules/
|
|
757
|
+
// src/analysis/rules/data-rules.ts
|
|
637
758
|
var stackTraceLeakRule = {
|
|
638
759
|
id: "stack-trace-leak",
|
|
639
760
|
severity: "critical",
|
|
640
761
|
name: "Stack Trace Leaked to Client",
|
|
641
762
|
hint: RULE_HINTS["stack-trace-leak"],
|
|
642
763
|
check(ctx) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
endpoint: ep,
|
|
661
|
-
count: 1
|
|
764
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
765
|
+
if (!request.responseBody) return null;
|
|
766
|
+
if (!STACK_TRACE_RE.test(request.responseBody)) return null;
|
|
767
|
+
const ep = `${request.method} ${request.path}`;
|
|
768
|
+
const firstLine = request.responseBody.split("\n").find((l) => STACK_TRACE_RE.test(l))?.trim() ?? "";
|
|
769
|
+
return {
|
|
770
|
+
key: ep,
|
|
771
|
+
finding: {
|
|
772
|
+
severity: "critical",
|
|
773
|
+
rule: "stack-trace-leak",
|
|
774
|
+
title: "Stack Trace Leaked to Client",
|
|
775
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
776
|
+
hint: this.hint,
|
|
777
|
+
detail: firstLine ? `Stack trace: ${firstLine.slice(0, 120)}` : void 0,
|
|
778
|
+
endpoint: ep,
|
|
779
|
+
count: 1
|
|
780
|
+
}
|
|
662
781
|
};
|
|
663
|
-
|
|
664
|
-
findings.push(finding);
|
|
665
|
-
}
|
|
666
|
-
return findings;
|
|
782
|
+
});
|
|
667
783
|
}
|
|
668
784
|
};
|
|
669
|
-
|
|
670
|
-
// src/analysis/rules/error-info-leak.ts
|
|
671
785
|
var CRITICAL_PATTERNS = [
|
|
672
786
|
{ re: DB_CONN_RE, label: "database connection string" },
|
|
673
787
|
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
@@ -679,90 +793,35 @@ var errorInfoLeakRule = {
|
|
|
679
793
|
name: "Sensitive Data in Error Response",
|
|
680
794
|
hint: RULE_HINTS["error-info-leak"],
|
|
681
795
|
check(ctx) {
|
|
682
|
-
const
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
if (
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const dedupKey = `${ep}:${p.label}`;
|
|
692
|
-
const existing = seen.get(dedupKey);
|
|
693
|
-
if (existing) {
|
|
694
|
-
existing.count++;
|
|
695
|
-
continue;
|
|
796
|
+
const entries = [];
|
|
797
|
+
for (const request of ctx.requests) {
|
|
798
|
+
if (request.statusCode < 400) continue;
|
|
799
|
+
if (!request.responseBody) continue;
|
|
800
|
+
if (request.responseHeaders["x-nextjs-error"] || request.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
801
|
+
const ep = `${request.method} ${request.path}`;
|
|
802
|
+
for (const pattern of CRITICAL_PATTERNS) {
|
|
803
|
+
if (pattern.re.test(request.responseBody)) {
|
|
804
|
+
entries.push({ ep, pattern, body: request.responseBody });
|
|
696
805
|
}
|
|
697
|
-
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return deduplicateFindings(entries, ({ ep, pattern }) => {
|
|
809
|
+
return {
|
|
810
|
+
key: `${ep}:${pattern.label}`,
|
|
811
|
+
finding: {
|
|
698
812
|
severity: "critical",
|
|
699
813
|
rule: "error-info-leak",
|
|
700
814
|
title: "Sensitive Data in Error Response",
|
|
701
|
-
desc: `${ep} \u2014 error response exposes ${
|
|
815
|
+
desc: `${ep} \u2014 error response exposes ${pattern.label}`,
|
|
702
816
|
hint: this.hint,
|
|
817
|
+
detail: `Detected: ${pattern.label} in error response body`,
|
|
703
818
|
endpoint: ep,
|
|
704
819
|
count: 1
|
|
705
|
-
};
|
|
706
|
-
seen.set(dedupKey, finding);
|
|
707
|
-
findings.push(finding);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
return findings;
|
|
711
|
-
}
|
|
712
|
-
};
|
|
713
|
-
|
|
714
|
-
// src/analysis/rules/insecure-cookie.ts
|
|
715
|
-
function isFrameworkResponse(r) {
|
|
716
|
-
if (isRedirect(r.statusCode)) return true;
|
|
717
|
-
if (r.path?.startsWith("/__")) return true;
|
|
718
|
-
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
719
|
-
return false;
|
|
720
|
-
}
|
|
721
|
-
var insecureCookieRule = {
|
|
722
|
-
id: "insecure-cookie",
|
|
723
|
-
severity: "warning",
|
|
724
|
-
name: "Insecure Cookie",
|
|
725
|
-
hint: RULE_HINTS["insecure-cookie"],
|
|
726
|
-
check(ctx) {
|
|
727
|
-
const findings = [];
|
|
728
|
-
const seen = /* @__PURE__ */ new Map();
|
|
729
|
-
for (const r of ctx.requests) {
|
|
730
|
-
if (!r.responseHeaders) continue;
|
|
731
|
-
if (isFrameworkResponse(r)) continue;
|
|
732
|
-
const setCookie = r.responseHeaders["set-cookie"];
|
|
733
|
-
if (!setCookie) continue;
|
|
734
|
-
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
735
|
-
for (const cookie of cookies) {
|
|
736
|
-
const cookieName = cookie.trim().split("=")[0].trim();
|
|
737
|
-
const lower = cookie.toLowerCase();
|
|
738
|
-
const issues = [];
|
|
739
|
-
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
740
|
-
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
741
|
-
if (issues.length === 0) continue;
|
|
742
|
-
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
743
|
-
const existing = seen.get(dedupKey);
|
|
744
|
-
if (existing) {
|
|
745
|
-
existing.count++;
|
|
746
|
-
continue;
|
|
747
820
|
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
rule: "insecure-cookie",
|
|
751
|
-
title: "Insecure Cookie",
|
|
752
|
-
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
753
|
-
hint: this.hint,
|
|
754
|
-
endpoint: cookieName,
|
|
755
|
-
count: 1
|
|
756
|
-
};
|
|
757
|
-
seen.set(dedupKey, finding);
|
|
758
|
-
findings.push(finding);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
return findings;
|
|
821
|
+
};
|
|
822
|
+
});
|
|
762
823
|
}
|
|
763
824
|
};
|
|
764
|
-
|
|
765
|
-
// src/analysis/rules/sensitive-logs.ts
|
|
766
825
|
var sensitiveLogsRule = {
|
|
767
826
|
id: "sensitive-logs",
|
|
768
827
|
severity: "warning",
|
|
@@ -787,58 +846,13 @@ var sensitiveLogsRule = {
|
|
|
787
846
|
}];
|
|
788
847
|
}
|
|
789
848
|
};
|
|
790
|
-
|
|
791
|
-
// src/analysis/rules/cors-credentials.ts
|
|
792
|
-
var corsCredentialsRule = {
|
|
793
|
-
id: "cors-credentials",
|
|
794
|
-
severity: "warning",
|
|
795
|
-
name: "CORS Credentials with Wildcard",
|
|
796
|
-
hint: RULE_HINTS["cors-credentials"],
|
|
797
|
-
check(ctx) {
|
|
798
|
-
const findings = [];
|
|
799
|
-
const seen = /* @__PURE__ */ new Set();
|
|
800
|
-
for (const r of ctx.requests) {
|
|
801
|
-
if (!r.responseHeaders) continue;
|
|
802
|
-
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
803
|
-
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
804
|
-
if (origin !== "*" || creds !== "true") continue;
|
|
805
|
-
const ep = `${r.method} ${r.path}`;
|
|
806
|
-
if (seen.has(ep)) continue;
|
|
807
|
-
seen.add(ep);
|
|
808
|
-
findings.push({
|
|
809
|
-
severity: "warning",
|
|
810
|
-
rule: "cors-credentials",
|
|
811
|
-
title: "CORS Credentials with Wildcard",
|
|
812
|
-
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
813
|
-
hint: this.hint,
|
|
814
|
-
endpoint: ep,
|
|
815
|
-
count: 1
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
return findings;
|
|
819
|
-
}
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
// src/analysis/rules/response-pii-leak.ts
|
|
823
849
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
824
|
-
function findEmails(obj
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
emails.push(...findEmails(obj[i], depth + 1));
|
|
831
|
-
}
|
|
832
|
-
return emails;
|
|
833
|
-
}
|
|
834
|
-
for (const v of Object.values(obj)) {
|
|
835
|
-
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
836
|
-
emails.push(v);
|
|
837
|
-
} else if (typeof v === "object" && v !== null) {
|
|
838
|
-
emails.push(...findEmails(v, depth + 1));
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
return emails;
|
|
850
|
+
function findEmails(obj) {
|
|
851
|
+
return collectFromObject(
|
|
852
|
+
obj,
|
|
853
|
+
(_key, val) => typeof val === "string" && EMAIL_RE.test(val) ? val : null,
|
|
854
|
+
{ arrayLimit: PII_SCAN_ARRAY_LIMIT }
|
|
855
|
+
);
|
|
842
856
|
}
|
|
843
857
|
function topLevelFieldCount(obj) {
|
|
844
858
|
if (Array.isArray(obj)) {
|
|
@@ -854,6 +868,15 @@ function hasInternalIds(obj) {
|
|
|
854
868
|
}
|
|
855
869
|
return false;
|
|
856
870
|
}
|
|
871
|
+
function hasSensitiveFieldNames(obj, depth = 0) {
|
|
872
|
+
if (depth >= MAX_OBJECT_SCAN_DEPTH) return false;
|
|
873
|
+
if (!obj || typeof obj !== "object") return false;
|
|
874
|
+
if (Array.isArray(obj)) return obj.length > 0 && hasSensitiveFieldNames(obj[0], depth + 1);
|
|
875
|
+
for (const key of Object.keys(obj)) {
|
|
876
|
+
if (SENSITIVE_FIELD_NAMES.test(key)) return true;
|
|
877
|
+
}
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
857
880
|
function detectEchoPII(method, reqBody, target) {
|
|
858
881
|
if (!WRITE_METHODS.has(method) || !reqBody || typeof reqBody !== "object") return null;
|
|
859
882
|
const reqEmails = findEmails(reqBody);
|
|
@@ -875,6 +898,13 @@ function detectFullRecordPII(target) {
|
|
|
875
898
|
if (emails.length === 0) return null;
|
|
876
899
|
return { reason: "full-record", emailCount: emails.length };
|
|
877
900
|
}
|
|
901
|
+
function detectSensitiveFieldPII(target) {
|
|
902
|
+
const inspect = Array.isArray(target) && target.length > 0 ? target[0] : target;
|
|
903
|
+
if (!inspect || typeof inspect !== "object" || Array.isArray(inspect)) return null;
|
|
904
|
+
if (!hasSensitiveFieldNames(inspect)) return null;
|
|
905
|
+
if (!hasInternalIds(inspect) && topLevelFieldCount(inspect) < FULL_RECORD_MIN_FIELDS) return null;
|
|
906
|
+
return { reason: "sensitive-fields", emailCount: 0 };
|
|
907
|
+
}
|
|
878
908
|
function detectListPII(target) {
|
|
879
909
|
if (!Array.isArray(target) || target.length < LIST_PII_MIN_ITEMS) return null;
|
|
880
910
|
let itemsWithEmail = 0;
|
|
@@ -893,12 +923,13 @@ function detectListPII(target) {
|
|
|
893
923
|
}
|
|
894
924
|
function detectPII(method, reqBody, resBody) {
|
|
895
925
|
const target = unwrapResponse(resBody);
|
|
896
|
-
return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target);
|
|
926
|
+
return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target) ?? detectSensitiveFieldPII(target);
|
|
897
927
|
}
|
|
898
928
|
var REASON_LABELS = {
|
|
899
929
|
echo: "echoes back PII from the request body",
|
|
900
930
|
"full-record": "returns a full record with email and internal IDs",
|
|
901
|
-
"list-pii": "returns a list of records containing email addresses"
|
|
931
|
+
"list-pii": "returns a list of records containing email addresses",
|
|
932
|
+
"sensitive-fields": "contains sensitive personal data fields (phone, SSN, date of birth, address, etc.)"
|
|
902
933
|
};
|
|
903
934
|
var responsePiiLeakRule = {
|
|
904
935
|
id: "response-pii-leak",
|
|
@@ -906,35 +937,33 @@ var responsePiiLeakRule = {
|
|
|
906
937
|
name: "PII Leak in Response",
|
|
907
938
|
hint: RULE_HINTS["response-pii-leak"],
|
|
908
939
|
check(ctx) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
if (
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
940
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
941
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
942
|
+
if (SELF_SERVICE_PATH.test(request.path)) return null;
|
|
943
|
+
const resJson = ctx.parsedBodies.response.get(request.id);
|
|
944
|
+
if (!resJson) return null;
|
|
945
|
+
const reqJson = ctx.parsedBodies.request.get(request.id) ?? null;
|
|
946
|
+
const detection = detectPII(request.method, reqJson, resJson);
|
|
947
|
+
if (!detection) return null;
|
|
948
|
+
const ep = `${request.method} ${request.path}`;
|
|
949
|
+
const fieldCount = topLevelFieldCount(resJson);
|
|
950
|
+
const detailParts = [`Pattern: ${REASON_LABELS[detection.reason]}`];
|
|
951
|
+
if (detection.emailCount > 0) detailParts.push(`${detection.emailCount} email${detection.emailCount !== 1 ? "s" : ""} detected`);
|
|
952
|
+
if (fieldCount > 0) detailParts.push(`${fieldCount} fields per record`);
|
|
953
|
+
return {
|
|
954
|
+
key: ep,
|
|
955
|
+
finding: {
|
|
956
|
+
severity: "warning",
|
|
957
|
+
rule: "response-pii-leak",
|
|
958
|
+
title: "PII Leak in Response",
|
|
959
|
+
desc: `${ep} \u2014 exposes PII in response`,
|
|
960
|
+
hint: this.hint,
|
|
961
|
+
detail: detailParts.join(". "),
|
|
962
|
+
endpoint: ep,
|
|
963
|
+
count: 1
|
|
964
|
+
}
|
|
933
965
|
};
|
|
934
|
-
|
|
935
|
-
findings.push(finding);
|
|
936
|
-
}
|
|
937
|
-
return findings;
|
|
966
|
+
});
|
|
938
967
|
}
|
|
939
968
|
};
|
|
940
969
|
|
|
@@ -942,20 +971,22 @@ var responsePiiLeakRule = {
|
|
|
942
971
|
function buildBodyCache(requests) {
|
|
943
972
|
const response = /* @__PURE__ */ new Map();
|
|
944
973
|
const request = /* @__PURE__ */ new Map();
|
|
945
|
-
for (const
|
|
946
|
-
if (
|
|
947
|
-
const parsed = tryParseJson(
|
|
948
|
-
if (parsed != null) response.set(
|
|
974
|
+
for (const req of requests) {
|
|
975
|
+
if (req.responseBody) {
|
|
976
|
+
const parsed = tryParseJson(req.responseBody);
|
|
977
|
+
if (parsed != null) response.set(req.id, parsed);
|
|
949
978
|
}
|
|
950
|
-
if (
|
|
951
|
-
const parsed = tryParseJson(
|
|
952
|
-
if (parsed != null) request.set(
|
|
979
|
+
if (req.requestBody) {
|
|
980
|
+
const parsed = tryParseJson(req.requestBody);
|
|
981
|
+
if (parsed != null) request.set(req.id, parsed);
|
|
953
982
|
}
|
|
954
983
|
}
|
|
955
984
|
return { response, request };
|
|
956
985
|
}
|
|
957
986
|
var SecurityScanner = class {
|
|
958
|
-
|
|
987
|
+
constructor() {
|
|
988
|
+
this.rules = [];
|
|
989
|
+
}
|
|
959
990
|
register(rule) {
|
|
960
991
|
this.rules.push(rule);
|
|
961
992
|
}
|
|
@@ -968,7 +999,8 @@ var SecurityScanner = class {
|
|
|
968
999
|
for (const rule of this.rules) {
|
|
969
1000
|
try {
|
|
970
1001
|
findings.push(...rule.check(ctx));
|
|
971
|
-
} catch {
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
brakitDebug(`rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
972
1004
|
}
|
|
973
1005
|
}
|
|
974
1006
|
return findings;
|
|
@@ -992,7 +1024,9 @@ function createDefaultScanner() {
|
|
|
992
1024
|
|
|
993
1025
|
// src/core/disposable.ts
|
|
994
1026
|
var SubscriptionBag = class {
|
|
995
|
-
|
|
1027
|
+
constructor() {
|
|
1028
|
+
this.items = [];
|
|
1029
|
+
}
|
|
996
1030
|
add(teardown) {
|
|
997
1031
|
this.items.push(typeof teardown === "function" ? { dispose: teardown } : teardown);
|
|
998
1032
|
}
|
|
@@ -1005,7 +1039,7 @@ var SubscriptionBag = class {
|
|
|
1005
1039
|
// src/analysis/group.ts
|
|
1006
1040
|
import { randomUUID } from "crypto";
|
|
1007
1041
|
|
|
1008
|
-
// src/constants/
|
|
1042
|
+
// src/constants/labels.ts
|
|
1009
1043
|
var DASHBOARD_PREFIX = "/__brakit";
|
|
1010
1044
|
var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
|
|
1011
1045
|
var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
|
|
@@ -1037,13 +1071,40 @@ var VALID_TABS_TUPLE = [
|
|
|
1037
1071
|
];
|
|
1038
1072
|
var VALID_TABS = new Set(VALID_TABS_TUPLE);
|
|
1039
1073
|
|
|
1040
|
-
// src/constants/
|
|
1074
|
+
// src/constants/features.ts
|
|
1041
1075
|
var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
|
|
1042
1076
|
|
|
1077
|
+
// src/utils/endpoint.ts
|
|
1078
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1079
|
+
var NUMERIC_ID_RE = /^\d+$/;
|
|
1080
|
+
var HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
|
|
1081
|
+
var ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
|
|
1082
|
+
function isDynamicSegment(segment) {
|
|
1083
|
+
return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
|
|
1084
|
+
}
|
|
1085
|
+
var DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
|
|
1086
|
+
function normalizePath(path) {
|
|
1087
|
+
const qIdx = path.indexOf("?");
|
|
1088
|
+
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1089
|
+
return pathname.split("/").map((seg) => seg && isDynamicSegment(seg) ? DYNAMIC_SEGMENT_PLACEHOLDER : seg).join("/");
|
|
1090
|
+
}
|
|
1091
|
+
function getEndpointKey(method, path) {
|
|
1092
|
+
return `${method} ${normalizePath(path)}`;
|
|
1093
|
+
}
|
|
1094
|
+
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1095
|
+
function extractEndpointFromDesc(desc) {
|
|
1096
|
+
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1097
|
+
}
|
|
1098
|
+
function stripQueryString(path) {
|
|
1099
|
+
const i = path.indexOf("?");
|
|
1100
|
+
return i === -1 ? path : path.slice(0, i);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1043
1103
|
// src/analysis/categorize.ts
|
|
1044
1104
|
function detectCategory(req) {
|
|
1045
1105
|
const { method, url, statusCode, responseHeaders } = req;
|
|
1046
1106
|
if (req.isStatic) return "static";
|
|
1107
|
+
if (req.isHealthCheck) return "health-check";
|
|
1047
1108
|
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
1048
1109
|
return "auth-handshake";
|
|
1049
1110
|
}
|
|
@@ -1195,20 +1256,42 @@ function capitalize(s) {
|
|
|
1195
1256
|
}
|
|
1196
1257
|
|
|
1197
1258
|
// src/analysis/transforms.ts
|
|
1198
|
-
|
|
1259
|
+
var DUPLICATE_CATEGORIES = /* @__PURE__ */ new Set(["data-fetch", "auth-check"]);
|
|
1260
|
+
function isDuplicateCandidate(req) {
|
|
1261
|
+
return DUPLICATE_CATEGORIES.has(req.category);
|
|
1262
|
+
}
|
|
1263
|
+
function buildRequestKey(req) {
|
|
1264
|
+
return `${req.method} ${stripQueryString(getEffectivePath(req))}`;
|
|
1265
|
+
}
|
|
1266
|
+
function isStrictModePattern(requests, counts) {
|
|
1267
|
+
if (counts.size === 0 || ![...counts.values()].every((c) => c === 2)) {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
const firstByKey = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const req of requests) {
|
|
1272
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1273
|
+
const key = buildRequestKey(req);
|
|
1274
|
+
const first = firstByKey.get(key);
|
|
1275
|
+
if (!first) {
|
|
1276
|
+
firstByKey.set(key, req);
|
|
1277
|
+
} else if (Math.abs(req.startedAt - first.startedAt) > STRICT_MODE_MAX_GAP_MS) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
function flagDuplicateRequests(requests) {
|
|
1199
1284
|
const counts = /* @__PURE__ */ new Map();
|
|
1200
1285
|
for (const req of requests) {
|
|
1201
|
-
if (req
|
|
1202
|
-
|
|
1203
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1286
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1287
|
+
const key = buildRequestKey(req);
|
|
1204
1288
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1205
1289
|
}
|
|
1206
|
-
const isStrictMode =
|
|
1290
|
+
const isStrictMode = isStrictModePattern(requests, counts);
|
|
1207
1291
|
const seen = /* @__PURE__ */ new Set();
|
|
1208
1292
|
for (const req of requests) {
|
|
1209
|
-
if (req
|
|
1210
|
-
|
|
1211
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1293
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1294
|
+
const key = buildRequestKey(req);
|
|
1212
1295
|
if (seen.has(key)) {
|
|
1213
1296
|
if (isStrictMode) {
|
|
1214
1297
|
req.isStrictModeDupe = true;
|
|
@@ -1220,20 +1303,20 @@ function markDuplicates(requests) {
|
|
|
1220
1303
|
}
|
|
1221
1304
|
}
|
|
1222
1305
|
}
|
|
1223
|
-
function
|
|
1306
|
+
function mergePollingSequences(requests) {
|
|
1224
1307
|
const result = [];
|
|
1225
1308
|
let i = 0;
|
|
1226
1309
|
while (i < requests.length) {
|
|
1227
1310
|
const current = requests[i];
|
|
1228
|
-
const currentEffective = getEffectivePath(current)
|
|
1311
|
+
const currentEffective = stripQueryString(getEffectivePath(current));
|
|
1229
1312
|
if (current.method === "GET" && current.category === "data-fetch") {
|
|
1230
|
-
let
|
|
1231
|
-
while (
|
|
1232
|
-
|
|
1313
|
+
let nextIndex = i + 1;
|
|
1314
|
+
while (nextIndex < requests.length && requests[nextIndex].method === "GET" && stripQueryString(getEffectivePath(requests[nextIndex])) === currentEffective) {
|
|
1315
|
+
nextIndex++;
|
|
1233
1316
|
}
|
|
1234
|
-
const count =
|
|
1317
|
+
const count = nextIndex - i;
|
|
1235
1318
|
if (count >= MIN_POLLING_SEQUENCE) {
|
|
1236
|
-
const last = requests[
|
|
1319
|
+
const last = requests[nextIndex - 1];
|
|
1237
1320
|
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
1238
1321
|
const endpointName = prettifyEndpoint(currentEffective);
|
|
1239
1322
|
result.push({
|
|
@@ -1244,7 +1327,7 @@ function collapsePolling(requests) {
|
|
|
1244
1327
|
pollingDurationMs: pollingDuration,
|
|
1245
1328
|
isDuplicate: false
|
|
1246
1329
|
});
|
|
1247
|
-
i =
|
|
1330
|
+
i = nextIndex;
|
|
1248
1331
|
continue;
|
|
1249
1332
|
}
|
|
1250
1333
|
}
|
|
@@ -1257,18 +1340,18 @@ function formatDurationLabel(ms) {
|
|
|
1257
1340
|
if (ms < 1e3) return `${ms}ms`;
|
|
1258
1341
|
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1259
1342
|
}
|
|
1260
|
-
function
|
|
1343
|
+
function collectRequestWarnings(requests) {
|
|
1261
1344
|
const warnings = [];
|
|
1262
1345
|
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
1263
1346
|
if (duplicateCount > 0) {
|
|
1264
1347
|
const unique = new Set(
|
|
1265
|
-
requests.filter((r) => r.isDuplicate).map((r) =>
|
|
1348
|
+
requests.filter((r) => r.isDuplicate).map((r) => buildRequestKey(r))
|
|
1266
1349
|
);
|
|
1267
1350
|
const endpoints = unique.size;
|
|
1268
1351
|
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
1269
|
-
const key =
|
|
1352
|
+
const key = buildRequestKey(r);
|
|
1270
1353
|
const first = requests.find(
|
|
1271
|
-
(o) => !o.isDuplicate &&
|
|
1354
|
+
(o) => !o.isDuplicate && buildRequestKey(o) === key
|
|
1272
1355
|
);
|
|
1273
1356
|
return first && first.responseBody === r.responseBody;
|
|
1274
1357
|
});
|
|
@@ -1291,6 +1374,14 @@ function detectWarnings(requests) {
|
|
|
1291
1374
|
}
|
|
1292
1375
|
|
|
1293
1376
|
// src/analysis/group.ts
|
|
1377
|
+
function shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, startedAt) {
|
|
1378
|
+
if (currentRequests.length === 0) return false;
|
|
1379
|
+
const sourcePage = labeled.sourcePage;
|
|
1380
|
+
const isNewPage = sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1381
|
+
const isTimeGap = startedAt - lastEndTime > FLOW_GAP_MS;
|
|
1382
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1383
|
+
return isNewPage || isTimeGap || isPageLoad;
|
|
1384
|
+
}
|
|
1294
1385
|
function groupRequestsIntoFlows(requests) {
|
|
1295
1386
|
if (requests.length === 0) return [];
|
|
1296
1387
|
const flows = [];
|
|
@@ -1301,17 +1392,12 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1301
1392
|
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
1302
1393
|
const labeled = labelRequest(req);
|
|
1303
1394
|
if (labeled.category === "static") continue;
|
|
1304
|
-
|
|
1305
|
-
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
1306
|
-
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1307
|
-
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1308
|
-
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
1309
|
-
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
1395
|
+
if (shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, req.startedAt)) {
|
|
1310
1396
|
flows.push(buildFlow(currentRequests));
|
|
1311
1397
|
currentRequests = [];
|
|
1312
1398
|
}
|
|
1313
1399
|
currentRequests.push(labeled);
|
|
1314
|
-
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
1400
|
+
currentSourcePage = labeled.sourcePage ?? currentSourcePage;
|
|
1315
1401
|
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
1316
1402
|
}
|
|
1317
1403
|
if (currentRequests.length > 0) {
|
|
@@ -1320,8 +1406,8 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1320
1406
|
return flows;
|
|
1321
1407
|
}
|
|
1322
1408
|
function buildFlow(rawRequests) {
|
|
1323
|
-
|
|
1324
|
-
const requests =
|
|
1409
|
+
flagDuplicateRequests(rawRequests);
|
|
1410
|
+
const requests = mergePollingSequences(rawRequests);
|
|
1325
1411
|
const first = requests[0];
|
|
1326
1412
|
const startTime = first.startedAt;
|
|
1327
1413
|
const endTime = Math.max(
|
|
@@ -1340,7 +1426,7 @@ function buildFlow(rawRequests) {
|
|
|
1340
1426
|
startTime,
|
|
1341
1427
|
totalDurationMs: Math.round(endTime - startTime),
|
|
1342
1428
|
hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
|
|
1343
|
-
warnings:
|
|
1429
|
+
warnings: collectRequestWarnings(rawRequests),
|
|
1344
1430
|
sourcePage,
|
|
1345
1431
|
redundancyPct
|
|
1346
1432
|
};
|
|
@@ -1352,20 +1438,20 @@ function getDominantSourcePage(requests) {
|
|
|
1352
1438
|
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
1353
1439
|
}
|
|
1354
1440
|
}
|
|
1355
|
-
let
|
|
1356
|
-
let
|
|
1441
|
+
let mostCommonPage = "";
|
|
1442
|
+
let highestCount = 0;
|
|
1357
1443
|
for (const [page, count] of counts) {
|
|
1358
|
-
if (count >
|
|
1359
|
-
|
|
1360
|
-
|
|
1444
|
+
if (count > highestCount) {
|
|
1445
|
+
mostCommonPage = page;
|
|
1446
|
+
highestCount = count;
|
|
1361
1447
|
}
|
|
1362
1448
|
}
|
|
1363
|
-
return
|
|
1449
|
+
return mostCommonPage || (requests[0]?.path ? stripQueryString(requests[0].path) : "") || "/";
|
|
1364
1450
|
}
|
|
1365
1451
|
function deriveFlowLabel(requests, sourcePage) {
|
|
1366
1452
|
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];
|
|
1367
1453
|
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
1368
|
-
const pageName = prettifyPageName(trigger.path
|
|
1454
|
+
const pageName = prettifyPageName(stripQueryString(trigger.path));
|
|
1369
1455
|
return `${pageName} Page`;
|
|
1370
1456
|
}
|
|
1371
1457
|
if (trigger.category === "api-call") {
|
|
@@ -1390,67 +1476,23 @@ function deriveFlowLabel(requests, sourcePage) {
|
|
|
1390
1476
|
return trigger.label;
|
|
1391
1477
|
}
|
|
1392
1478
|
|
|
1393
|
-
// src/utils/collections.ts
|
|
1394
|
-
function groupBy(items, keyFn) {
|
|
1395
|
-
const map = /* @__PURE__ */ new Map();
|
|
1396
|
-
for (const item of items) {
|
|
1397
|
-
const key = keyFn(item);
|
|
1398
|
-
if (key == null) continue;
|
|
1399
|
-
let arr = map.get(key);
|
|
1400
|
-
if (!arr) {
|
|
1401
|
-
arr = [];
|
|
1402
|
-
map.set(key, arr);
|
|
1403
|
-
}
|
|
1404
|
-
arr.push(item);
|
|
1405
|
-
}
|
|
1406
|
-
return map;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// src/utils/endpoint.ts
|
|
1410
|
-
var DYNAMIC_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^\d+$|^[0-9a-f]{12,}$|^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/i;
|
|
1411
|
-
function normalizePath(path) {
|
|
1412
|
-
const qIdx = path.indexOf("?");
|
|
1413
|
-
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1414
|
-
return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
|
|
1415
|
-
}
|
|
1416
|
-
function getEndpointKey(method, path) {
|
|
1417
|
-
return `${method} ${normalizePath(path)}`;
|
|
1418
|
-
}
|
|
1419
|
-
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1420
|
-
function extractEndpointFromDesc(desc) {
|
|
1421
|
-
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
1479
|
// src/instrument/adapters/normalize.ts
|
|
1480
|
+
var VALID_OPS = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
1481
|
+
var TABLE_RE = /(?:FROM|INTO|UPDATE)\s+(?:"?\w+"?\.)?"?(\w+)"?/i;
|
|
1425
1482
|
function normalizeSQL(sql) {
|
|
1426
1483
|
if (!sql) return { op: "OTHER", table: "" };
|
|
1427
1484
|
const trimmed = sql.trim();
|
|
1428
|
-
const
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
case "SELECT":
|
|
1437
|
-
return { op: "SELECT", table };
|
|
1438
|
-
case "INSERT":
|
|
1439
|
-
return { op: "INSERT", table };
|
|
1440
|
-
case "UPDATE":
|
|
1441
|
-
return { op: "UPDATE", table };
|
|
1442
|
-
case "DELETE":
|
|
1443
|
-
return { op: "DELETE", table };
|
|
1444
|
-
default:
|
|
1445
|
-
return { op: "OTHER", table };
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1485
|
+
const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
|
|
1486
|
+
const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
|
|
1487
|
+
const table = trimmed.match(TABLE_RE)?.[1] ?? "";
|
|
1488
|
+
return { op, table };
|
|
1489
|
+
}
|
|
1490
|
+
var SQL_PARAM_MARKER = /\$\d+/g;
|
|
1491
|
+
var SQL_STRING_LITERAL = /'[^']*'/g;
|
|
1492
|
+
var SQL_NUMBER_LITERAL = /\b\d+(\.\d+)?\b/g;
|
|
1448
1493
|
function normalizeQueryParams(sql) {
|
|
1449
1494
|
if (!sql) return null;
|
|
1450
|
-
|
|
1451
|
-
n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
|
|
1452
|
-
n = n.replace(/\$\d+/g, "?");
|
|
1453
|
-
return n;
|
|
1495
|
+
return sql.replace(SQL_PARAM_MARKER, "?").replace(SQL_STRING_LITERAL, "?").replace(SQL_NUMBER_LITERAL, "?");
|
|
1454
1496
|
}
|
|
1455
1497
|
|
|
1456
1498
|
// src/analysis/insights/query-helpers.ts
|
|
@@ -1467,7 +1509,7 @@ function getQueryInfo(q) {
|
|
|
1467
1509
|
}
|
|
1468
1510
|
|
|
1469
1511
|
// src/analysis/insights/prepare.ts
|
|
1470
|
-
function
|
|
1512
|
+
function emptyEndpointGroup() {
|
|
1471
1513
|
return {
|
|
1472
1514
|
total: 0,
|
|
1473
1515
|
errors: 0,
|
|
@@ -1479,16 +1521,12 @@ function createEndpointGroup() {
|
|
|
1479
1521
|
queryShapeDurations: /* @__PURE__ */ new Map()
|
|
1480
1522
|
};
|
|
1481
1523
|
}
|
|
1482
|
-
function
|
|
1524
|
+
function keepRecentPerEndpoint(requests) {
|
|
1483
1525
|
const byEndpoint = /* @__PURE__ */ new Map();
|
|
1484
|
-
for (const
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
list = [];
|
|
1489
|
-
byEndpoint.set(ep, list);
|
|
1490
|
-
}
|
|
1491
|
-
list.push(r);
|
|
1526
|
+
for (const request of requests) {
|
|
1527
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1528
|
+
const list = getOrCreate(byEndpoint, endpointKey, () => []);
|
|
1529
|
+
list.push(request);
|
|
1492
1530
|
}
|
|
1493
1531
|
const windowed = [];
|
|
1494
1532
|
for (const [, reqs] of byEndpoint) {
|
|
@@ -1496,54 +1534,67 @@ function windowByEndpoint(requests) {
|
|
|
1496
1534
|
}
|
|
1497
1535
|
return windowed;
|
|
1498
1536
|
}
|
|
1537
|
+
function filterUserRequests(requests) {
|
|
1538
|
+
return requests.filter(
|
|
1539
|
+
(request) => !request.isStatic && !request.isHealthCheck && (!request.path || !request.path.startsWith(DASHBOARD_PREFIX))
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1499
1542
|
function extractActiveEndpoints(requests) {
|
|
1500
1543
|
const endpoints = /* @__PURE__ */ new Set();
|
|
1501
|
-
for (const
|
|
1502
|
-
|
|
1503
|
-
endpoints.add(getEndpointKey(r.method, r.path));
|
|
1504
|
-
}
|
|
1544
|
+
for (const request of filterUserRequests(requests)) {
|
|
1545
|
+
endpoints.add(getEndpointKey(request.method, request.path));
|
|
1505
1546
|
}
|
|
1506
1547
|
return endpoints;
|
|
1507
1548
|
}
|
|
1508
|
-
function
|
|
1509
|
-
const nonStatic = ctx.requests.filter(
|
|
1510
|
-
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1511
|
-
);
|
|
1512
|
-
const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
|
|
1513
|
-
const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
|
|
1514
|
-
const reqById = new Map(nonStatic.map((r) => [r.id, r]));
|
|
1515
|
-
const recent = windowByEndpoint(nonStatic);
|
|
1549
|
+
function aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq) {
|
|
1516
1550
|
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1517
|
-
for (const
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1551
|
+
for (const request of recent) {
|
|
1552
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1553
|
+
const group = getOrCreate(endpointGroups, endpointKey, emptyEndpointGroup);
|
|
1554
|
+
group.total++;
|
|
1555
|
+
if (isErrorStatus(request.statusCode)) group.errors++;
|
|
1556
|
+
group.totalDuration += request.durationMs;
|
|
1557
|
+
group.totalSize += request.responseSize ?? 0;
|
|
1558
|
+
const reqQueries = queriesByReq.get(request.id) ?? [];
|
|
1559
|
+
group.queryCount += reqQueries.length;
|
|
1560
|
+
for (const query of reqQueries) {
|
|
1561
|
+
group.totalQueryTimeMs += query.durationMs;
|
|
1562
|
+
const shape = getQueryShape(query);
|
|
1563
|
+
const info = getQueryInfo(query);
|
|
1564
|
+
const shapeDuration = getOrCreate(group.queryShapeDurations, shape, () => ({
|
|
1565
|
+
totalMs: 0,
|
|
1566
|
+
count: 0,
|
|
1567
|
+
label: info.op + (info.table ? ` ${info.table}` : "")
|
|
1568
|
+
}));
|
|
1569
|
+
shapeDuration.totalMs += query.durationMs;
|
|
1570
|
+
shapeDuration.count++;
|
|
1571
|
+
}
|
|
1572
|
+
const reqFetches = fetchesByReq.get(request.id) ?? [];
|
|
1573
|
+
for (const fetch of reqFetches) {
|
|
1574
|
+
group.totalFetchTimeMs += fetch.durationMs;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return endpointGroups;
|
|
1578
|
+
}
|
|
1579
|
+
function collectStrictModeDupeIds(ctx) {
|
|
1580
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1581
|
+
for (const flow of ctx.flows) {
|
|
1582
|
+
for (const req of flow.requests) {
|
|
1583
|
+
if (req.isStrictModeDupe) ids.add(req.id);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return ids;
|
|
1587
|
+
}
|
|
1588
|
+
function buildInsightContext(ctx) {
|
|
1589
|
+
const strictModeDupeIds = collectStrictModeDupeIds(ctx);
|
|
1590
|
+
const nonStatic = filterUserRequests(ctx.requests).filter((req) => !strictModeDupeIds.has(req.id));
|
|
1591
|
+
const filteredQueries = strictModeDupeIds.size > 0 ? ctx.queries.filter((q) => !q.parentRequestId || !strictModeDupeIds.has(q.parentRequestId)) : ctx.queries;
|
|
1592
|
+
const filteredFetches = strictModeDupeIds.size > 0 ? ctx.fetches.filter((f) => !f.parentRequestId || !strictModeDupeIds.has(f.parentRequestId)) : ctx.fetches;
|
|
1593
|
+
const queriesByReq = groupBy(filteredQueries, (query) => query.parentRequestId);
|
|
1594
|
+
const fetchesByReq = groupBy(filteredFetches, (fetch) => fetch.parentRequestId);
|
|
1595
|
+
const reqById = new Map(nonStatic.map((request) => [request.id, request]));
|
|
1596
|
+
const recent = keepRecentPerEndpoint(nonStatic);
|
|
1597
|
+
const endpointGroups = aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq);
|
|
1547
1598
|
return {
|
|
1548
1599
|
...ctx,
|
|
1549
1600
|
nonStatic,
|
|
@@ -1557,17 +1608,20 @@ function prepareContext(ctx) {
|
|
|
1557
1608
|
// src/analysis/insights/runner.ts
|
|
1558
1609
|
var SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
1559
1610
|
var InsightRunner = class {
|
|
1560
|
-
|
|
1611
|
+
constructor() {
|
|
1612
|
+
this.rules = [];
|
|
1613
|
+
}
|
|
1561
1614
|
register(rule) {
|
|
1562
1615
|
this.rules.push(rule);
|
|
1563
1616
|
}
|
|
1564
1617
|
run(ctx) {
|
|
1565
|
-
const prepared =
|
|
1618
|
+
const prepared = buildInsightContext(ctx);
|
|
1566
1619
|
const insights = [];
|
|
1567
1620
|
for (const rule of this.rules) {
|
|
1568
1621
|
try {
|
|
1569
1622
|
insights.push(...rule.check(prepared));
|
|
1570
|
-
} catch {
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
brakitDebug(`insight rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
1571
1625
|
}
|
|
1572
1626
|
}
|
|
1573
1627
|
insights.sort(
|
|
@@ -1577,338 +1631,121 @@ var InsightRunner = class {
|
|
|
1577
1631
|
}
|
|
1578
1632
|
};
|
|
1579
1633
|
|
|
1580
|
-
// src/analysis/insights/rules/
|
|
1634
|
+
// src/analysis/insights/rules/query-rules.ts
|
|
1581
1635
|
var n1Rule = {
|
|
1582
1636
|
id: "n1",
|
|
1583
1637
|
check(ctx) {
|
|
1584
1638
|
const insights = [];
|
|
1585
|
-
const
|
|
1639
|
+
const reportedKeys = /* @__PURE__ */ new Set();
|
|
1586
1640
|
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1587
1641
|
const req = ctx.reqById.get(reqId);
|
|
1588
1642
|
if (!req) continue;
|
|
1589
1643
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1590
1644
|
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1591
|
-
for (const
|
|
1592
|
-
const shape = getQueryShape(
|
|
1645
|
+
for (const query of reqQueries) {
|
|
1646
|
+
const shape = getQueryShape(query);
|
|
1593
1647
|
let group = shapeGroups.get(shape);
|
|
1594
1648
|
if (!group) {
|
|
1595
|
-
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first:
|
|
1649
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: query };
|
|
1596
1650
|
shapeGroups.set(shape, group);
|
|
1597
1651
|
}
|
|
1598
1652
|
group.count++;
|
|
1599
|
-
group.distinctSql.add(
|
|
1653
|
+
group.distinctSql.add(query.sql ?? shape);
|
|
1600
1654
|
}
|
|
1601
|
-
for (const [,
|
|
1602
|
-
if (
|
|
1603
|
-
const info = getQueryInfo(
|
|
1655
|
+
for (const [, shapeGroup] of shapeGroups) {
|
|
1656
|
+
if (shapeGroup.count <= N1_QUERY_THRESHOLD || shapeGroup.distinctSql.size <= 1) continue;
|
|
1657
|
+
const info = getQueryInfo(shapeGroup.first);
|
|
1604
1658
|
const key = `${endpoint}:${info.op}:${info.table || "unknown"}`;
|
|
1605
|
-
if (
|
|
1606
|
-
|
|
1659
|
+
if (reportedKeys.has(key)) continue;
|
|
1660
|
+
reportedKeys.add(key);
|
|
1607
1661
|
insights.push({
|
|
1608
1662
|
severity: "critical",
|
|
1609
1663
|
type: "n1",
|
|
1610
1664
|
title: "N+1 Query Pattern",
|
|
1611
|
-
desc: `${endpoint} runs ${
|
|
1665
|
+
desc: `${endpoint} runs ${shapeGroup.count}x ${info.op} ${info.table} with different params in a single request`,
|
|
1612
1666
|
hint: "This typically happens when fetching related data in a loop. Use a batch query, JOIN, or include/eager-load to fetch all records at once.",
|
|
1613
|
-
|
|
1614
|
-
});
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
return insights;
|
|
1618
|
-
}
|
|
1619
|
-
};
|
|
1620
|
-
|
|
1621
|
-
// src/analysis/insights/rules/cross-endpoint.ts
|
|
1622
|
-
var crossEndpointRule = {
|
|
1623
|
-
id: "cross-endpoint",
|
|
1624
|
-
check(ctx) {
|
|
1625
|
-
const insights = [];
|
|
1626
|
-
const queryMap = /* @__PURE__ */ new Map();
|
|
1627
|
-
const allEndpoints = /* @__PURE__ */ new Set();
|
|
1628
|
-
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1629
|
-
const req = ctx.reqById.get(reqId);
|
|
1630
|
-
if (!req) continue;
|
|
1631
|
-
const endpoint = getEndpointKey(req.method, req.path);
|
|
1632
|
-
allEndpoints.add(endpoint);
|
|
1633
|
-
const seenInReq = /* @__PURE__ */ new Set();
|
|
1634
|
-
for (const q of reqQueries) {
|
|
1635
|
-
const shape = getQueryShape(q);
|
|
1636
|
-
let entry = queryMap.get(shape);
|
|
1637
|
-
if (!entry) {
|
|
1638
|
-
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1639
|
-
queryMap.set(shape, entry);
|
|
1640
|
-
}
|
|
1641
|
-
entry.count++;
|
|
1642
|
-
if (!seenInReq.has(shape)) {
|
|
1643
|
-
seenInReq.add(shape);
|
|
1644
|
-
entry.endpoints.add(endpoint);
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1649
|
-
for (const [, cem] of queryMap) {
|
|
1650
|
-
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1651
|
-
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1652
|
-
const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
|
|
1653
|
-
if (p < CROSS_ENDPOINT_PCT) continue;
|
|
1654
|
-
const info = getQueryInfo(cem.first);
|
|
1655
|
-
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1656
|
-
insights.push({
|
|
1657
|
-
severity: "warning",
|
|
1658
|
-
type: "cross-endpoint",
|
|
1659
|
-
title: "Repeated Query Across Endpoints",
|
|
1660
|
-
desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
|
|
1661
|
-
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1662
|
-
nav: "queries"
|
|
1667
|
+
detail: `${shapeGroup.count} queries with ${shapeGroup.distinctSql.size} distinct param variations. Example: ${[...shapeGroup.distinctSql][0]?.slice(0, 100) ?? info.op + " " + info.table}`
|
|
1663
1668
|
});
|
|
1664
1669
|
}
|
|
1665
1670
|
}
|
|
1666
1671
|
return insights;
|
|
1667
1672
|
}
|
|
1668
1673
|
};
|
|
1669
|
-
|
|
1670
|
-
// src/analysis/insights/rules/redundant-query.ts
|
|
1671
1674
|
var redundantQueryRule = {
|
|
1672
1675
|
id: "redundant-query",
|
|
1673
1676
|
check(ctx) {
|
|
1674
1677
|
const insights = [];
|
|
1675
|
-
const
|
|
1678
|
+
const reportedKeys = /* @__PURE__ */ new Set();
|
|
1676
1679
|
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1677
1680
|
const req = ctx.reqById.get(reqId);
|
|
1678
1681
|
if (!req) continue;
|
|
1679
1682
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1680
|
-
const
|
|
1681
|
-
for (const
|
|
1682
|
-
if (!
|
|
1683
|
-
let entry =
|
|
1683
|
+
const identicalQueryMap = /* @__PURE__ */ new Map();
|
|
1684
|
+
for (const query of reqQueries) {
|
|
1685
|
+
if (!query.sql) continue;
|
|
1686
|
+
let entry = identicalQueryMap.get(query.sql);
|
|
1684
1687
|
if (!entry) {
|
|
1685
|
-
entry = { count: 0, first:
|
|
1686
|
-
|
|
1688
|
+
entry = { count: 0, first: query };
|
|
1689
|
+
identicalQueryMap.set(query.sql, entry);
|
|
1687
1690
|
}
|
|
1688
1691
|
entry.count++;
|
|
1689
1692
|
}
|
|
1690
|
-
for (const [,
|
|
1691
|
-
if (
|
|
1692
|
-
const info = getQueryInfo(
|
|
1693
|
+
for (const [, entry] of identicalQueryMap) {
|
|
1694
|
+
if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1695
|
+
const info = getQueryInfo(entry.first);
|
|
1693
1696
|
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
1696
|
-
|
|
1697
|
+
const deduplicationKey = `${endpoint}:${label}`;
|
|
1698
|
+
if (reportedKeys.has(deduplicationKey)) continue;
|
|
1699
|
+
reportedKeys.add(deduplicationKey);
|
|
1697
1700
|
insights.push({
|
|
1698
1701
|
severity: "warning",
|
|
1699
1702
|
type: "redundant-query",
|
|
1700
1703
|
title: "Redundant Query",
|
|
1701
|
-
desc: `${label} runs ${
|
|
1704
|
+
desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
|
|
1702
1705
|
hint: "The exact same query with identical parameters runs multiple times in one request. Cache the first result or lift the query to a shared function.",
|
|
1703
|
-
|
|
1704
|
-
});
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
return insights;
|
|
1708
|
-
}
|
|
1709
|
-
};
|
|
1710
|
-
|
|
1711
|
-
// src/analysis/insights/rules/error.ts
|
|
1712
|
-
var errorRule = {
|
|
1713
|
-
id: "error",
|
|
1714
|
-
check(ctx) {
|
|
1715
|
-
if (ctx.errors.length === 0) return [];
|
|
1716
|
-
const insights = [];
|
|
1717
|
-
const groups = /* @__PURE__ */ new Map();
|
|
1718
|
-
for (const e of ctx.errors) {
|
|
1719
|
-
const name = e.name || "Error";
|
|
1720
|
-
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1721
|
-
}
|
|
1722
|
-
for (const [name, cnt] of groups) {
|
|
1723
|
-
insights.push({
|
|
1724
|
-
severity: "critical",
|
|
1725
|
-
type: "error",
|
|
1726
|
-
title: "Unhandled Error",
|
|
1727
|
-
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1728
|
-
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1729
|
-
nav: "errors"
|
|
1730
|
-
});
|
|
1731
|
-
}
|
|
1732
|
-
return insights;
|
|
1733
|
-
}
|
|
1734
|
-
};
|
|
1735
|
-
|
|
1736
|
-
// src/analysis/insights/rules/error-hotspot.ts
|
|
1737
|
-
var errorHotspotRule = {
|
|
1738
|
-
id: "error-hotspot",
|
|
1739
|
-
check(ctx) {
|
|
1740
|
-
const insights = [];
|
|
1741
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1742
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1743
|
-
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1744
|
-
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1745
|
-
insights.push({
|
|
1746
|
-
severity: "critical",
|
|
1747
|
-
type: "error-hotspot",
|
|
1748
|
-
title: "Error Hotspot",
|
|
1749
|
-
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1750
|
-
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1751
|
-
nav: "requests"
|
|
1752
|
-
});
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
return insights;
|
|
1756
|
-
}
|
|
1757
|
-
};
|
|
1758
|
-
|
|
1759
|
-
// src/analysis/insights/rules/duplicate.ts
|
|
1760
|
-
var duplicateRule = {
|
|
1761
|
-
id: "duplicate",
|
|
1762
|
-
check(ctx) {
|
|
1763
|
-
const dupCounts = /* @__PURE__ */ new Map();
|
|
1764
|
-
const flowCount = /* @__PURE__ */ new Map();
|
|
1765
|
-
for (const flow of ctx.flows) {
|
|
1766
|
-
if (!flow.requests) continue;
|
|
1767
|
-
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1768
|
-
for (const fr of flow.requests) {
|
|
1769
|
-
if (!fr.isDuplicate) continue;
|
|
1770
|
-
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1771
|
-
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1772
|
-
if (!seenInFlow.has(dupKey)) {
|
|
1773
|
-
seenInFlow.add(dupKey);
|
|
1774
|
-
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1779
|
-
const insights = [];
|
|
1780
|
-
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
1781
|
-
const d = dupEntries[i];
|
|
1782
|
-
insights.push({
|
|
1783
|
-
severity: "warning",
|
|
1784
|
-
type: "duplicate",
|
|
1785
|
-
title: "Duplicate API Call",
|
|
1786
|
-
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1787
|
-
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR.",
|
|
1788
|
-
nav: "actions"
|
|
1789
|
-
});
|
|
1790
|
-
}
|
|
1791
|
-
return insights;
|
|
1792
|
-
}
|
|
1793
|
-
};
|
|
1794
|
-
|
|
1795
|
-
// src/utils/format.ts
|
|
1796
|
-
function formatDuration(ms) {
|
|
1797
|
-
if (ms < 1e3) return `${ms}ms`;
|
|
1798
|
-
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1799
|
-
}
|
|
1800
|
-
function formatSize(bytes) {
|
|
1801
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
1802
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1803
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1804
|
-
}
|
|
1805
|
-
function pct(part, total) {
|
|
1806
|
-
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
// src/analysis/insights/rules/slow.ts
|
|
1810
|
-
var slowRule = {
|
|
1811
|
-
id: "slow",
|
|
1812
|
-
check(ctx) {
|
|
1813
|
-
const insights = [];
|
|
1814
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1815
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1816
|
-
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1817
|
-
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1818
|
-
const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
|
|
1819
|
-
const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
|
|
1820
|
-
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1821
|
-
const parts = [];
|
|
1822
|
-
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1823
|
-
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1824
|
-
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1825
|
-
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1826
|
-
let detail;
|
|
1827
|
-
let slowestMs = 0;
|
|
1828
|
-
for (const [, sd] of g.queryShapeDurations) {
|
|
1829
|
-
const avgShapeMs = sd.totalMs / sd.count;
|
|
1830
|
-
if (avgShapeMs > slowestMs) {
|
|
1831
|
-
slowestMs = avgShapeMs;
|
|
1832
|
-
detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
insights.push({
|
|
1836
|
-
severity: "warning",
|
|
1837
|
-
type: "slow",
|
|
1838
|
-
title: "Slow Endpoint",
|
|
1839
|
-
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1840
|
-
hint: avgQueryMs >= avgFetchMs && avgQueryMs >= avgAppMs ? "Most time is in database queries. Check the Queries tab for slow or redundant queries." : avgFetchMs >= avgQueryMs && avgFetchMs >= avgAppMs ? "Most time is in outbound HTTP calls. Check if upstream services are slow or if calls can be parallelized." : "Most time is in application code. Profile the handler for CPU-heavy operations or blocking calls.",
|
|
1841
|
-
detail,
|
|
1842
|
-
nav: "requests"
|
|
1843
|
-
});
|
|
1844
|
-
}
|
|
1845
|
-
return insights;
|
|
1846
|
-
}
|
|
1847
|
-
};
|
|
1848
|
-
|
|
1849
|
-
// src/analysis/insights/rules/query-heavy.ts
|
|
1850
|
-
var queryHeavyRule = {
|
|
1851
|
-
id: "query-heavy",
|
|
1852
|
-
check(ctx) {
|
|
1853
|
-
const insights = [];
|
|
1854
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1855
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1856
|
-
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1857
|
-
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1858
|
-
insights.push({
|
|
1859
|
-
severity: "warning",
|
|
1860
|
-
type: "query-heavy",
|
|
1861
|
-
title: "Query-Heavy Endpoint",
|
|
1862
|
-
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1863
|
-
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1864
|
-
nav: "queries"
|
|
1706
|
+
detail: entry.first.sql ? `Query: ${entry.first.sql.slice(0, 120)}` : void 0
|
|
1865
1707
|
});
|
|
1866
1708
|
}
|
|
1867
1709
|
}
|
|
1868
1710
|
return insights;
|
|
1869
1711
|
}
|
|
1870
1712
|
};
|
|
1871
|
-
|
|
1872
|
-
// src/analysis/insights/rules/select-star.ts
|
|
1873
1713
|
var selectStarRule = {
|
|
1874
1714
|
id: "select-star",
|
|
1875
1715
|
check(ctx) {
|
|
1876
|
-
const
|
|
1716
|
+
const tableCounts = /* @__PURE__ */ new Map();
|
|
1877
1717
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1878
|
-
for (const
|
|
1879
|
-
if (!
|
|
1880
|
-
const isSelectStar = SELECT_STAR_RE.test(
|
|
1718
|
+
for (const query of reqQueries) {
|
|
1719
|
+
if (!query.sql) continue;
|
|
1720
|
+
const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
|
|
1881
1721
|
if (!isSelectStar) continue;
|
|
1882
|
-
const info = getQueryInfo(
|
|
1883
|
-
const
|
|
1884
|
-
|
|
1722
|
+
const info = getQueryInfo(query);
|
|
1723
|
+
const table = info.table || "unknown";
|
|
1724
|
+
tableCounts.set(table, (tableCounts.get(table) ?? 0) + 1);
|
|
1885
1725
|
}
|
|
1886
1726
|
}
|
|
1887
1727
|
const insights = [];
|
|
1888
|
-
for (const [table, count] of
|
|
1728
|
+
for (const [table, count] of tableCounts) {
|
|
1889
1729
|
if (count < OVERFETCH_MIN_REQUESTS) continue;
|
|
1890
1730
|
insights.push({
|
|
1891
1731
|
severity: "warning",
|
|
1892
1732
|
type: "select-star",
|
|
1893
1733
|
title: "SELECT * Query",
|
|
1894
1734
|
desc: `SELECT * on ${table} \u2014 ${count} occurrence${count !== 1 ? "s" : ""}`,
|
|
1895
|
-
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage."
|
|
1896
|
-
nav: "queries"
|
|
1735
|
+
hint: "SELECT * fetches all columns including ones you don\u2019t need. Specify only required columns to reduce data transfer and memory usage."
|
|
1897
1736
|
});
|
|
1898
1737
|
}
|
|
1899
1738
|
return insights;
|
|
1900
1739
|
}
|
|
1901
1740
|
};
|
|
1902
|
-
|
|
1903
|
-
// src/analysis/insights/rules/high-rows.ts
|
|
1904
1741
|
var highRowsRule = {
|
|
1905
1742
|
id: "high-rows",
|
|
1906
1743
|
check(ctx) {
|
|
1907
1744
|
const seen = /* @__PURE__ */ new Map();
|
|
1908
1745
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1909
|
-
for (const
|
|
1910
|
-
if (!
|
|
1911
|
-
const info = getQueryInfo(
|
|
1746
|
+
for (const query of reqQueries) {
|
|
1747
|
+
if (!query.rowCount || query.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1748
|
+
const info = getQueryInfo(query);
|
|
1912
1749
|
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1913
1750
|
let entry = seen.get(key);
|
|
1914
1751
|
if (!entry) {
|
|
@@ -1916,7 +1753,7 @@ var highRowsRule = {
|
|
|
1916
1753
|
seen.set(key, entry);
|
|
1917
1754
|
}
|
|
1918
1755
|
entry.count++;
|
|
1919
|
-
if (
|
|
1756
|
+
if (query.rowCount > entry.max) entry.max = query.rowCount;
|
|
1920
1757
|
}
|
|
1921
1758
|
}
|
|
1922
1759
|
const insights = [];
|
|
@@ -1927,28 +1764,62 @@ var highRowsRule = {
|
|
|
1927
1764
|
type: "high-rows",
|
|
1928
1765
|
title: "Large Result Set",
|
|
1929
1766
|
desc: `${key} returns ${hrs.max}+ rows (${hrs.count}x)`,
|
|
1930
|
-
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition."
|
|
1931
|
-
nav: "queries"
|
|
1767
|
+
hint: "Fetching many rows slows responses and wastes memory. Add a LIMIT clause, implement pagination, or filter with a WHERE condition."
|
|
1932
1768
|
});
|
|
1933
1769
|
}
|
|
1934
1770
|
return insights;
|
|
1935
1771
|
}
|
|
1936
1772
|
};
|
|
1773
|
+
var queryHeavyRule = {
|
|
1774
|
+
id: "query-heavy",
|
|
1775
|
+
check(ctx) {
|
|
1776
|
+
const insights = [];
|
|
1777
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1778
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1779
|
+
const avgQueries = Math.round(group.queryCount / group.total);
|
|
1780
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1781
|
+
insights.push({
|
|
1782
|
+
severity: "warning",
|
|
1783
|
+
type: "query-heavy",
|
|
1784
|
+
title: "Query-Heavy Endpoint",
|
|
1785
|
+
desc: `${endpointKey} \u2014 avg ${avgQueries} queries/request`,
|
|
1786
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches."
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return insights;
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
// src/utils/format.ts
|
|
1795
|
+
function formatDuration(ms) {
|
|
1796
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1797
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1798
|
+
}
|
|
1799
|
+
function formatSize(bytes) {
|
|
1800
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1801
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1802
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1803
|
+
}
|
|
1804
|
+
function pct(part, total) {
|
|
1805
|
+
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1806
|
+
}
|
|
1937
1807
|
|
|
1938
|
-
// src/analysis/insights/rules/response-
|
|
1808
|
+
// src/analysis/insights/rules/response-rules.ts
|
|
1939
1809
|
var responseOverfetchRule = {
|
|
1940
1810
|
id: "response-overfetch",
|
|
1941
1811
|
check(ctx) {
|
|
1942
1812
|
const insights = [];
|
|
1943
1813
|
const seen = /* @__PURE__ */ new Set();
|
|
1944
|
-
for (const
|
|
1945
|
-
if (isErrorStatus(
|
|
1946
|
-
const
|
|
1947
|
-
if (seen.has(
|
|
1814
|
+
for (const request of ctx.nonStatic) {
|
|
1815
|
+
if (isErrorStatus(request.statusCode) || !request.responseBody) continue;
|
|
1816
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1817
|
+
if (seen.has(endpointKey)) continue;
|
|
1948
1818
|
let parsed;
|
|
1949
1819
|
try {
|
|
1950
|
-
parsed = JSON.parse(
|
|
1951
|
-
} catch {
|
|
1820
|
+
parsed = JSON.parse(request.responseBody);
|
|
1821
|
+
} catch (e) {
|
|
1822
|
+
brakitDebug(`json parse: ${getErrorMessage(e)}`);
|
|
1952
1823
|
continue;
|
|
1953
1824
|
}
|
|
1954
1825
|
const target = unwrapResponse(parsed);
|
|
@@ -1971,37 +1842,33 @@ var responseOverfetchRule = {
|
|
|
1971
1842
|
reasons.push(`${fields.length} fields returned`);
|
|
1972
1843
|
}
|
|
1973
1844
|
if (reasons.length > 0) {
|
|
1974
|
-
seen.add(
|
|
1845
|
+
seen.add(endpointKey);
|
|
1975
1846
|
insights.push({
|
|
1976
1847
|
severity: "info",
|
|
1977
1848
|
type: "response-overfetch",
|
|
1978
1849
|
title: "Response Overfetch",
|
|
1979
|
-
desc: `${
|
|
1980
|
-
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."
|
|
1981
|
-
nav: "requests"
|
|
1850
|
+
desc: `${endpointKey} \u2014 ${reasons.join(", ")}`,
|
|
1851
|
+
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."
|
|
1982
1852
|
});
|
|
1983
1853
|
}
|
|
1984
1854
|
}
|
|
1985
1855
|
return insights;
|
|
1986
1856
|
}
|
|
1987
1857
|
};
|
|
1988
|
-
|
|
1989
|
-
// src/analysis/insights/rules/large-response.ts
|
|
1990
1858
|
var largeResponseRule = {
|
|
1991
1859
|
id: "large-response",
|
|
1992
1860
|
check(ctx) {
|
|
1993
1861
|
const insights = [];
|
|
1994
|
-
for (const [
|
|
1995
|
-
if (
|
|
1996
|
-
const avgSize = Math.round(
|
|
1862
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1863
|
+
if (group.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1864
|
+
const avgSize = Math.round(group.totalSize / group.total);
|
|
1997
1865
|
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
1998
1866
|
insights.push({
|
|
1999
1867
|
severity: "info",
|
|
2000
1868
|
type: "large-response",
|
|
2001
1869
|
title: "Large Response",
|
|
2002
|
-
desc: `${
|
|
2003
|
-
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression."
|
|
2004
|
-
nav: "requests"
|
|
1870
|
+
desc: `${endpointKey} \u2014 avg ${formatSize(avgSize)} response`,
|
|
1871
|
+
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression."
|
|
2005
1872
|
});
|
|
2006
1873
|
}
|
|
2007
1874
|
}
|
|
@@ -2009,7 +1876,50 @@ var largeResponseRule = {
|
|
|
2009
1876
|
}
|
|
2010
1877
|
};
|
|
2011
1878
|
|
|
2012
|
-
// src/analysis/insights/rules/
|
|
1879
|
+
// src/analysis/insights/rules/reliability-rules.ts
|
|
1880
|
+
var errorRule = {
|
|
1881
|
+
id: "error",
|
|
1882
|
+
check(ctx) {
|
|
1883
|
+
if (ctx.errors.length === 0) return [];
|
|
1884
|
+
const insights = [];
|
|
1885
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1886
|
+
for (const error of ctx.errors) {
|
|
1887
|
+
const name = error.name || "Error";
|
|
1888
|
+
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1889
|
+
}
|
|
1890
|
+
for (const [name, cnt] of groups) {
|
|
1891
|
+
insights.push({
|
|
1892
|
+
severity: "critical",
|
|
1893
|
+
type: "error",
|
|
1894
|
+
title: "Unhandled Error",
|
|
1895
|
+
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1896
|
+
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1897
|
+
detail: ctx.errors.find((e) => e.name === name)?.message
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
return insights;
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
var errorHotspotRule = {
|
|
1904
|
+
id: "error-hotspot",
|
|
1905
|
+
check(ctx) {
|
|
1906
|
+
const insights = [];
|
|
1907
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1908
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1909
|
+
const errorRate = Math.round(group.errors / group.total * 100);
|
|
1910
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1911
|
+
insights.push({
|
|
1912
|
+
severity: "critical",
|
|
1913
|
+
type: "error-hotspot",
|
|
1914
|
+
title: "Error Hotspot",
|
|
1915
|
+
desc: `${endpointKey} \u2014 ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
|
|
1916
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces."
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return insights;
|
|
1921
|
+
}
|
|
1922
|
+
};
|
|
2013
1923
|
var regressionRule = {
|
|
2014
1924
|
id: "regression",
|
|
2015
1925
|
check(ctx) {
|
|
@@ -2028,8 +1938,7 @@ var regressionRule = {
|
|
|
2028
1938
|
type: "regression",
|
|
2029
1939
|
title: "Performance Regression",
|
|
2030
1940
|
desc: `${epMetrics.endpoint} p95 degraded ${formatDuration(prev.p95DurationMs)} \u2192 ${formatDuration(current.p95DurationMs)} (+${p95PctChange}%)`,
|
|
2031
|
-
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing."
|
|
2032
|
-
nav: "graph"
|
|
1941
|
+
hint: "This endpoint is slower than the previous session. Check if recent code changes added queries or processing."
|
|
2033
1942
|
});
|
|
2034
1943
|
}
|
|
2035
1944
|
if (prev.avgQueryCount > 0 && current.avgQueryCount > prev.avgQueryCount * QUERY_COUNT_REGRESSION_RATIO) {
|
|
@@ -2038,8 +1947,137 @@ var regressionRule = {
|
|
|
2038
1947
|
type: "regression",
|
|
2039
1948
|
title: "Query Count Regression",
|
|
2040
1949
|
desc: `${epMetrics.endpoint} queries/request increased ${prev.avgQueryCount} \u2192 ${current.avgQueryCount}`,
|
|
2041
|
-
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations."
|
|
2042
|
-
|
|
1950
|
+
hint: "This endpoint is making more database queries than before. Check for new N+1 patterns or removed query optimizations."
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return insights;
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
function getAdaptiveSlowThreshold(endpointKey, previousMetrics) {
|
|
1958
|
+
if (!previousMetrics) return null;
|
|
1959
|
+
const ep = previousMetrics.find((m) => m.endpoint === endpointKey);
|
|
1960
|
+
if (!ep || ep.sessions.length < BASELINE_MIN_SESSIONS) return null;
|
|
1961
|
+
const valid = ep.sessions.filter((s) => s.requestCount >= BASELINE_MIN_REQUESTS_PER_SESSION);
|
|
1962
|
+
if (valid.length < BASELINE_MIN_SESSIONS) return null;
|
|
1963
|
+
const p95s = valid.map((s) => s.p95DurationMs).sort((a, b) => a - b);
|
|
1964
|
+
const medianP95 = p95s[Math.floor(p95s.length / 2)];
|
|
1965
|
+
return medianP95 * 2;
|
|
1966
|
+
}
|
|
1967
|
+
var slowRule = {
|
|
1968
|
+
id: "slow",
|
|
1969
|
+
check(ctx) {
|
|
1970
|
+
const insights = [];
|
|
1971
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1972
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1973
|
+
const avgMs = Math.round(group.totalDuration / group.total);
|
|
1974
|
+
const threshold = getAdaptiveSlowThreshold(endpointKey, ctx.previousMetrics);
|
|
1975
|
+
if (threshold === null || avgMs < threshold) continue;
|
|
1976
|
+
const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
|
|
1977
|
+
const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
|
|
1978
|
+
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1979
|
+
const parts = [];
|
|
1980
|
+
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1981
|
+
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1982
|
+
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1983
|
+
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1984
|
+
let detail;
|
|
1985
|
+
let slowestMs = 0;
|
|
1986
|
+
for (const [, shapeDuration] of group.queryShapeDurations) {
|
|
1987
|
+
const avgShapeMs = shapeDuration.totalMs / shapeDuration.count;
|
|
1988
|
+
if (avgShapeMs > slowestMs) {
|
|
1989
|
+
slowestMs = avgShapeMs;
|
|
1990
|
+
detail = `Slowest query: ${shapeDuration.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${shapeDuration.count}x)`;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
insights.push({
|
|
1994
|
+
severity: "warning",
|
|
1995
|
+
type: "slow",
|
|
1996
|
+
title: "Slow Endpoint",
|
|
1997
|
+
desc: `${endpointKey} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1998
|
+
hint: avgQueryMs >= avgFetchMs && avgQueryMs >= avgAppMs ? "Most time is in database queries. Check the Queries tab for slow or redundant queries." : avgFetchMs >= avgQueryMs && avgFetchMs >= avgAppMs ? "Most time is in outbound HTTP calls. Check if upstream services are slow or if calls can be parallelized." : "Most time is in application code. Profile the handler for CPU-heavy operations or blocking calls.",
|
|
1999
|
+
detail
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
return insights;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
|
|
2006
|
+
// src/analysis/insights/rules/pattern-rules.ts
|
|
2007
|
+
var duplicateRule = {
|
|
2008
|
+
id: "duplicate",
|
|
2009
|
+
check(ctx) {
|
|
2010
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
2011
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
2012
|
+
for (const flow of ctx.flows) {
|
|
2013
|
+
if (!flow.requests) continue;
|
|
2014
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
2015
|
+
for (const request of flow.requests) {
|
|
2016
|
+
if (!request.isDuplicate) continue;
|
|
2017
|
+
const deduplicationKey = `${request.method} ${request.label ?? request.path ?? request.url}`;
|
|
2018
|
+
dupCounts.set(deduplicationKey, (dupCounts.get(deduplicationKey) ?? 0) + 1);
|
|
2019
|
+
if (!seenInFlow.has(deduplicationKey)) {
|
|
2020
|
+
seenInFlow.add(deduplicationKey);
|
|
2021
|
+
flowCount.set(deduplicationKey, (flowCount.get(deduplicationKey) ?? 0) + 1);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
2026
|
+
const insights = [];
|
|
2027
|
+
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
2028
|
+
const duplicate = dupEntries[i];
|
|
2029
|
+
insights.push({
|
|
2030
|
+
severity: "warning",
|
|
2031
|
+
type: "duplicate",
|
|
2032
|
+
title: "Duplicate API Call",
|
|
2033
|
+
desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
|
|
2034
|
+
hint: "Multiple components independently fetch the same endpoint. Lift the fetch to a parent component, use a data cache, or deduplicate with React Query / SWR."
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
return insights;
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
var crossEndpointRule = {
|
|
2041
|
+
id: "cross-endpoint",
|
|
2042
|
+
check(ctx) {
|
|
2043
|
+
const insights = [];
|
|
2044
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
2045
|
+
const allEndpoints = /* @__PURE__ */ new Set();
|
|
2046
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
2047
|
+
const req = ctx.reqById.get(reqId);
|
|
2048
|
+
if (!req) continue;
|
|
2049
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
2050
|
+
allEndpoints.add(endpoint);
|
|
2051
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
2052
|
+
for (const query of reqQueries) {
|
|
2053
|
+
const shape = getQueryShape(query);
|
|
2054
|
+
let entry = queryMap.get(shape);
|
|
2055
|
+
if (!entry) {
|
|
2056
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: query };
|
|
2057
|
+
queryMap.set(shape, entry);
|
|
2058
|
+
}
|
|
2059
|
+
entry.count++;
|
|
2060
|
+
if (!seenInReq.has(shape)) {
|
|
2061
|
+
seenInReq.add(shape);
|
|
2062
|
+
entry.endpoints.add(endpoint);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
2067
|
+
for (const [, queryMetric] of queryMap) {
|
|
2068
|
+
if (queryMetric.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
2069
|
+
if (queryMetric.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
2070
|
+
const coveragePct = Math.round(queryMetric.endpoints.size / allEndpoints.size * 100);
|
|
2071
|
+
if (coveragePct < CROSS_ENDPOINT_PCT) continue;
|
|
2072
|
+
const info = getQueryInfo(queryMetric.first);
|
|
2073
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
2074
|
+
insights.push({
|
|
2075
|
+
severity: "warning",
|
|
2076
|
+
type: "cross-endpoint",
|
|
2077
|
+
title: "Repeated Query Across Endpoints",
|
|
2078
|
+
desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
|
|
2079
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
2080
|
+
detail: `Endpoints: ${[...queryMetric.endpoints].slice(0, 5).join(", ")}${queryMetric.endpoints.size > 5 ? ` +${queryMetric.endpoints.size - 5} more` : ""}. Total: ${queryMetric.count} executions.`
|
|
2043
2081
|
});
|
|
2044
2082
|
}
|
|
2045
2083
|
}
|
|
@@ -2052,13 +2090,13 @@ var securityRule = {
|
|
|
2052
2090
|
id: "security",
|
|
2053
2091
|
check(ctx) {
|
|
2054
2092
|
if (!ctx.securityFindings) return [];
|
|
2055
|
-
return ctx.securityFindings.map((
|
|
2056
|
-
severity:
|
|
2093
|
+
return ctx.securityFindings.map((finding) => ({
|
|
2094
|
+
severity: finding.severity,
|
|
2057
2095
|
type: "security",
|
|
2058
|
-
title:
|
|
2059
|
-
desc:
|
|
2060
|
-
hint:
|
|
2061
|
-
|
|
2096
|
+
title: finding.title,
|
|
2097
|
+
desc: finding.desc,
|
|
2098
|
+
hint: finding.hint,
|
|
2099
|
+
detail: finding.detail
|
|
2062
2100
|
}));
|
|
2063
2101
|
}
|
|
2064
2102
|
};
|
|
@@ -2101,8 +2139,7 @@ function insightToIssue(insight) {
|
|
|
2101
2139
|
desc: insight.desc,
|
|
2102
2140
|
hint: insight.hint,
|
|
2103
2141
|
detail: insight.detail,
|
|
2104
|
-
endpoint: extractEndpointFromDesc(insight.desc) ?? void 0
|
|
2105
|
-
nav: insight.nav
|
|
2142
|
+
endpoint: extractEndpointFromDesc(insight.desc) ?? void 0
|
|
2106
2143
|
};
|
|
2107
2144
|
}
|
|
2108
2145
|
function securityFindingToIssue(finding) {
|
|
@@ -2113,25 +2150,24 @@ function securityFindingToIssue(finding) {
|
|
|
2113
2150
|
title: finding.title,
|
|
2114
2151
|
desc: finding.desc,
|
|
2115
2152
|
hint: finding.hint,
|
|
2116
|
-
|
|
2117
|
-
|
|
2153
|
+
detail: finding.detail,
|
|
2154
|
+
endpoint: finding.endpoint
|
|
2118
2155
|
};
|
|
2119
2156
|
}
|
|
2120
2157
|
|
|
2121
2158
|
// src/analysis/engine.ts
|
|
2122
2159
|
var AnalysisEngine = class {
|
|
2123
|
-
constructor(
|
|
2124
|
-
this.
|
|
2160
|
+
constructor(services, debounceMs = ANALYSIS_DEBOUNCE_MS) {
|
|
2161
|
+
this.services = services;
|
|
2125
2162
|
this.debounceMs = debounceMs;
|
|
2163
|
+
this.cachedInsights = [];
|
|
2164
|
+
this.cachedFindings = [];
|
|
2165
|
+
this.debounceTimer = null;
|
|
2166
|
+
this.subs = new SubscriptionBag();
|
|
2126
2167
|
this.scanner = createDefaultScanner();
|
|
2127
2168
|
}
|
|
2128
|
-
scanner;
|
|
2129
|
-
cachedInsights = [];
|
|
2130
|
-
cachedFindings = [];
|
|
2131
|
-
debounceTimer = null;
|
|
2132
|
-
subs = new SubscriptionBag();
|
|
2133
2169
|
start() {
|
|
2134
|
-
const bus = this.
|
|
2170
|
+
const bus = this.services.bus;
|
|
2135
2171
|
this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
|
|
2136
2172
|
this.subs.add(bus.on("telemetry:query", () => this.scheduleRecompute()));
|
|
2137
2173
|
this.subs.add(bus.on("telemetry:error", () => this.scheduleRecompute()));
|
|
@@ -2158,12 +2194,12 @@ var AnalysisEngine = class {
|
|
|
2158
2194
|
}, this.debounceMs);
|
|
2159
2195
|
}
|
|
2160
2196
|
recompute() {
|
|
2161
|
-
const allRequests = this.
|
|
2162
|
-
const queries = this.
|
|
2163
|
-
const errors = this.
|
|
2164
|
-
const logs = this.
|
|
2165
|
-
const fetches = this.
|
|
2166
|
-
const requests =
|
|
2197
|
+
const allRequests = this.services.requestStore.getAll();
|
|
2198
|
+
const queries = this.services.queryStore.getAll();
|
|
2199
|
+
const errors = this.services.errorStore.getAll();
|
|
2200
|
+
const logs = this.services.logStore.getAll();
|
|
2201
|
+
const fetches = this.services.fetchStore.getAll();
|
|
2202
|
+
const requests = keepRecentPerEndpoint(allRequests);
|
|
2167
2203
|
const flows = groupRequestsIntoFlows(requests);
|
|
2168
2204
|
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
2169
2205
|
this.cachedInsights = computeInsights({
|
|
@@ -2172,38 +2208,34 @@ var AnalysisEngine = class {
|
|
|
2172
2208
|
errors,
|
|
2173
2209
|
flows,
|
|
2174
2210
|
fetches,
|
|
2175
|
-
previousMetrics: this.
|
|
2211
|
+
previousMetrics: this.services.metricsStore.getAll(),
|
|
2176
2212
|
securityFindings: this.cachedFindings
|
|
2177
2213
|
});
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
const
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
issues: issueStore.getAll()
|
|
2199
|
-
};
|
|
2200
|
-
this.registry.get("event-bus").emit("analysis:updated", update);
|
|
2201
|
-
}
|
|
2214
|
+
const issueStore = this.services.issueStore;
|
|
2215
|
+
const currentIssueIds = /* @__PURE__ */ new Set();
|
|
2216
|
+
for (const finding of this.cachedFindings) {
|
|
2217
|
+
const issue = securityFindingToIssue(finding);
|
|
2218
|
+
issueStore.upsert(issue, "passive");
|
|
2219
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2220
|
+
}
|
|
2221
|
+
for (const insight of this.cachedInsights) {
|
|
2222
|
+
const issue = insightToIssue(insight);
|
|
2223
|
+
issueStore.upsert(issue, "passive");
|
|
2224
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2225
|
+
}
|
|
2226
|
+
const activeEndpoints = extractActiveEndpoints(allRequests);
|
|
2227
|
+
issueStore.reconcile(currentIssueIds, activeEndpoints);
|
|
2228
|
+
const update = {
|
|
2229
|
+
insights: this.cachedInsights,
|
|
2230
|
+
findings: this.cachedFindings,
|
|
2231
|
+
issues: issueStore.getAll()
|
|
2232
|
+
};
|
|
2233
|
+
this.services.bus.emit("analysis:updated", update);
|
|
2202
2234
|
}
|
|
2203
2235
|
};
|
|
2204
2236
|
|
|
2205
2237
|
// src/index.ts
|
|
2206
|
-
var VERSION = "0.
|
|
2238
|
+
var VERSION = "0.9.0";
|
|
2207
2239
|
export {
|
|
2208
2240
|
AdapterRegistry,
|
|
2209
2241
|
AnalysisEngine,
|