brakit 0.8.7 → 9.0.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 +15 -3
- package/dist/api.d.ts +66 -46
- package/dist/api.js +727 -730
- package/dist/bin/brakit.js +294 -432
- package/dist/dashboard-client.global.js +345 -222
- package/dist/dashboard.html +390 -231
- package/dist/mcp/server.js +7 -15
- package/dist/runtime/index.js +1367 -1607
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -10,7 +10,7 @@ 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;
|
|
@@ -21,6 +21,38 @@ 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 SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
|
|
31
|
+
var MIN_REQUESTS_FOR_INSIGHT = 2;
|
|
32
|
+
var HIGH_QUERY_COUNT_PER_REQ = 5;
|
|
33
|
+
var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
|
|
34
|
+
var CROSS_ENDPOINT_PCT = 50;
|
|
35
|
+
var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
|
|
36
|
+
var REDUNDANT_QUERY_MIN_COUNT = 2;
|
|
37
|
+
var LARGE_RESPONSE_BYTES = 51200;
|
|
38
|
+
var HIGH_ROW_COUNT = 100;
|
|
39
|
+
var OVERFETCH_MIN_REQUESTS = 2;
|
|
40
|
+
var OVERFETCH_MIN_FIELDS = 8;
|
|
41
|
+
var OVERFETCH_MIN_INTERNAL_IDS = 2;
|
|
42
|
+
var OVERFETCH_NULL_RATIO = 0.3;
|
|
43
|
+
var REGRESSION_PCT_THRESHOLD = 50;
|
|
44
|
+
var REGRESSION_MIN_INCREASE_MS = 200;
|
|
45
|
+
var REGRESSION_MIN_REQUESTS = 5;
|
|
46
|
+
var QUERY_COUNT_REGRESSION_RATIO = 1.5;
|
|
47
|
+
var OVERFETCH_MANY_FIELDS = 12;
|
|
48
|
+
var OVERFETCH_UNWRAP_MIN_SIZE = 3;
|
|
49
|
+
var MAX_DUPLICATE_INSIGHTS = 3;
|
|
50
|
+
var INSIGHT_WINDOW_PER_ENDPOINT = 20;
|
|
51
|
+
var CLEAN_HITS_FOR_RESOLUTION = 5;
|
|
52
|
+
var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
|
|
53
|
+
var STRICT_MODE_MAX_GAP_MS = 2e3;
|
|
54
|
+
var ISSUES_FILE = "issues.json";
|
|
55
|
+
var ISSUES_FLUSH_INTERVAL_MS = 1e4;
|
|
24
56
|
|
|
25
57
|
// src/utils/log.ts
|
|
26
58
|
var PREFIX = "[brakit]";
|
|
@@ -42,7 +74,9 @@ function getErrorMessage(err) {
|
|
|
42
74
|
return String(err);
|
|
43
75
|
}
|
|
44
76
|
function validateIssuesData(parsed) {
|
|
45
|
-
if (parsed
|
|
77
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
78
|
+
const obj = parsed;
|
|
79
|
+
if (obj.version === ISSUES_DATA_VERSION && Array.isArray(obj.issues)) {
|
|
46
80
|
return parsed;
|
|
47
81
|
}
|
|
48
82
|
return null;
|
|
@@ -86,41 +120,6 @@ async function ensureGitignoreAsync(dir, entry) {
|
|
|
86
120
|
}
|
|
87
121
|
}
|
|
88
122
|
|
|
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
123
|
// src/utils/atomic-writer.ts
|
|
125
124
|
import {
|
|
126
125
|
writeFileSync as writeFileSync2,
|
|
@@ -467,11 +466,12 @@ function tryParseJson(body) {
|
|
|
467
466
|
return null;
|
|
468
467
|
}
|
|
469
468
|
}
|
|
469
|
+
var MAX_WRAPPER_KEYS = 3;
|
|
470
470
|
function unwrapResponse(parsed) {
|
|
471
471
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
472
472
|
const obj = parsed;
|
|
473
473
|
const keys = Object.keys(obj);
|
|
474
|
-
if (keys.length >
|
|
474
|
+
if (keys.length > MAX_WRAPPER_KEYS) return parsed;
|
|
475
475
|
let best = null;
|
|
476
476
|
let bestSize = 0;
|
|
477
477
|
for (const key of keys) {
|
|
@@ -529,27 +529,87 @@ function isRedirect(code) {
|
|
|
529
529
|
return code >= 300 && code < 400;
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
-
// src/
|
|
533
|
-
function
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
532
|
+
// src/utils/collections.ts
|
|
533
|
+
function getOrCreate(map, key, create) {
|
|
534
|
+
let value = map.get(key);
|
|
535
|
+
if (value === void 0) {
|
|
536
|
+
value = create();
|
|
537
|
+
map.set(key, value);
|
|
538
|
+
}
|
|
539
|
+
return value;
|
|
540
|
+
}
|
|
541
|
+
function deduplicateFindings(items, extract) {
|
|
542
|
+
const seen = /* @__PURE__ */ new Map();
|
|
543
|
+
const findings = [];
|
|
544
|
+
for (const item of items) {
|
|
545
|
+
const result = extract(item);
|
|
546
|
+
if (!result) continue;
|
|
547
|
+
const existing = seen.get(result.key);
|
|
548
|
+
if (existing) {
|
|
549
|
+
existing.count++;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
seen.set(result.key, result.finding);
|
|
553
|
+
findings.push(result.finding);
|
|
554
|
+
}
|
|
555
|
+
return findings;
|
|
556
|
+
}
|
|
557
|
+
function groupBy(items, keyFn) {
|
|
558
|
+
const map = /* @__PURE__ */ new Map();
|
|
559
|
+
for (const item of items) {
|
|
560
|
+
const key = keyFn(item);
|
|
561
|
+
if (key == null) continue;
|
|
562
|
+
let arr = map.get(key);
|
|
563
|
+
if (!arr) {
|
|
564
|
+
arr = [];
|
|
565
|
+
map.set(key, arr);
|
|
540
566
|
}
|
|
541
|
-
|
|
567
|
+
arr.push(item);
|
|
542
568
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
569
|
+
return map;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/utils/object-scan.ts
|
|
573
|
+
var DEFAULTS = {
|
|
574
|
+
maxDepth: MAX_OBJECT_SCAN_DEPTH,
|
|
575
|
+
arrayLimit: SECRET_SCAN_ARRAY_LIMIT
|
|
576
|
+
};
|
|
577
|
+
function walkObject(obj, visitor, options) {
|
|
578
|
+
const opts = { ...DEFAULTS, ...options };
|
|
579
|
+
walk(obj, visitor, opts, 0);
|
|
580
|
+
}
|
|
581
|
+
function walk(obj, visitor, opts, depth) {
|
|
582
|
+
if (depth >= opts.maxDepth) return;
|
|
583
|
+
if (!obj || typeof obj !== "object") return;
|
|
584
|
+
if (Array.isArray(obj)) {
|
|
585
|
+
for (let i = 0; i < Math.min(obj.length, opts.arrayLimit); i++) {
|
|
586
|
+
walk(obj[i], visitor, opts, depth + 1);
|
|
547
587
|
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
for (const key of Object.keys(obj)) {
|
|
591
|
+
const val = obj[key];
|
|
592
|
+
visitor(key, val, depth);
|
|
548
593
|
if (typeof val === "object" && val !== null) {
|
|
549
|
-
|
|
594
|
+
walk(val, visitor, opts, depth + 1);
|
|
550
595
|
}
|
|
551
596
|
}
|
|
552
|
-
|
|
597
|
+
}
|
|
598
|
+
function collectFromObject(obj, match, options) {
|
|
599
|
+
const results = [];
|
|
600
|
+
walkObject(obj, (key, value) => {
|
|
601
|
+
const result = match(key, value);
|
|
602
|
+
if (result !== null) results.push(result);
|
|
603
|
+
}, options);
|
|
604
|
+
return results;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/analysis/rules/auth-rules.ts
|
|
608
|
+
function findSecretKeys(obj) {
|
|
609
|
+
return collectFromObject(
|
|
610
|
+
obj,
|
|
611
|
+
(key, val) => SECRET_KEYS.test(key) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val) ? key : null
|
|
612
|
+
);
|
|
553
613
|
}
|
|
554
614
|
var exposedSecretRule = {
|
|
555
615
|
id: "exposed-secret",
|
|
@@ -557,50 +617,38 @@ var exposedSecretRule = {
|
|
|
557
617
|
name: "Exposed Secret in Response",
|
|
558
618
|
hint: RULE_HINTS["exposed-secret"],
|
|
559
619
|
check(ctx) {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (
|
|
564
|
-
const
|
|
565
|
-
if (
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
title: "Exposed Secret in Response",
|
|
579
|
-
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
580
|
-
hint: this.hint,
|
|
581
|
-
endpoint: ep,
|
|
582
|
-
count: 1
|
|
620
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
621
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
622
|
+
const parsed = ctx.parsedBodies.response.get(request.id);
|
|
623
|
+
if (!parsed) return null;
|
|
624
|
+
const keys = findSecretKeys(parsed);
|
|
625
|
+
if (keys.length === 0) return null;
|
|
626
|
+
const ep = `${request.method} ${request.path}`;
|
|
627
|
+
return {
|
|
628
|
+
key: `${ep}:${keys.sort().join(",")}`,
|
|
629
|
+
finding: {
|
|
630
|
+
severity: "critical",
|
|
631
|
+
rule: "exposed-secret",
|
|
632
|
+
title: "Exposed Secret in Response",
|
|
633
|
+
desc: `${ep} \u2014 response contains ${keys.join(", ")} field${keys.length > 1 ? "s" : ""}`,
|
|
634
|
+
hint: this.hint,
|
|
635
|
+
endpoint: ep,
|
|
636
|
+
count: 1
|
|
637
|
+
}
|
|
583
638
|
};
|
|
584
|
-
|
|
585
|
-
findings.push(finding);
|
|
586
|
-
}
|
|
587
|
-
return findings;
|
|
639
|
+
});
|
|
588
640
|
}
|
|
589
641
|
};
|
|
590
|
-
|
|
591
|
-
// src/analysis/rules/token-in-url.ts
|
|
592
642
|
var tokenInUrlRule = {
|
|
593
643
|
id: "token-in-url",
|
|
594
644
|
severity: "critical",
|
|
595
645
|
name: "Auth Token in URL",
|
|
596
646
|
hint: RULE_HINTS["token-in-url"],
|
|
597
647
|
check(ctx) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
if (qIdx === -1) continue;
|
|
603
|
-
const params = r.url.substring(qIdx + 1).split("&");
|
|
648
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
649
|
+
const qIdx = request.url.indexOf("?");
|
|
650
|
+
if (qIdx === -1) return null;
|
|
651
|
+
const params = request.url.substring(qIdx + 1).split("&");
|
|
604
652
|
const flagged = [];
|
|
605
653
|
for (const param of params) {
|
|
606
654
|
const [name, ...rest] = param.split("=");
|
|
@@ -610,65 +658,124 @@ var tokenInUrlRule = {
|
|
|
610
658
|
flagged.push(name);
|
|
611
659
|
}
|
|
612
660
|
}
|
|
613
|
-
if (flagged.length === 0)
|
|
614
|
-
const ep = `${
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
661
|
+
if (flagged.length === 0) return null;
|
|
662
|
+
const ep = `${request.method} ${request.path}`;
|
|
663
|
+
return {
|
|
664
|
+
key: `${ep}:${flagged.sort().join(",")}`,
|
|
665
|
+
finding: {
|
|
666
|
+
severity: "critical",
|
|
667
|
+
rule: "token-in-url",
|
|
668
|
+
title: "Auth Token in URL",
|
|
669
|
+
desc: `${ep} \u2014 ${flagged.join(", ")} exposed in query string`,
|
|
670
|
+
hint: this.hint,
|
|
671
|
+
endpoint: ep,
|
|
672
|
+
count: 1
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function isFrameworkResponse(request) {
|
|
679
|
+
if (isRedirect(request.statusCode)) return true;
|
|
680
|
+
if (request.path?.startsWith("/__")) return true;
|
|
681
|
+
if (request.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
var insecureCookieRule = {
|
|
685
|
+
id: "insecure-cookie",
|
|
686
|
+
severity: "warning",
|
|
687
|
+
name: "Insecure Cookie",
|
|
688
|
+
hint: RULE_HINTS["insecure-cookie"],
|
|
689
|
+
check(ctx) {
|
|
690
|
+
const cookieEntries = [];
|
|
691
|
+
for (const request of ctx.requests) {
|
|
692
|
+
if (!request.responseHeaders) continue;
|
|
693
|
+
if (isFrameworkResponse(request)) continue;
|
|
694
|
+
const setCookie = request.responseHeaders["set-cookie"];
|
|
695
|
+
if (!setCookie) continue;
|
|
696
|
+
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
697
|
+
for (const cookie of cookies) {
|
|
698
|
+
cookieEntries.push({ cookie });
|
|
620
699
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
700
|
+
}
|
|
701
|
+
return deduplicateFindings(cookieEntries, ({ cookie }) => {
|
|
702
|
+
const cookieName = cookie.trim().split("=")[0].trim();
|
|
703
|
+
const lower = cookie.toLowerCase();
|
|
704
|
+
const issues = [];
|
|
705
|
+
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
706
|
+
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
707
|
+
if (issues.length === 0) return null;
|
|
708
|
+
return {
|
|
709
|
+
key: `${cookieName}:${issues.join(",")}`,
|
|
710
|
+
finding: {
|
|
711
|
+
severity: "warning",
|
|
712
|
+
rule: "insecure-cookie",
|
|
713
|
+
title: "Insecure Cookie",
|
|
714
|
+
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
715
|
+
hint: this.hint,
|
|
716
|
+
endpoint: cookieName,
|
|
717
|
+
count: 1
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
var corsCredentialsRule = {
|
|
724
|
+
id: "cors-credentials",
|
|
725
|
+
severity: "warning",
|
|
726
|
+
name: "CORS Credentials with Wildcard",
|
|
727
|
+
hint: RULE_HINTS["cors-credentials"],
|
|
728
|
+
check(ctx) {
|
|
729
|
+
const findings = [];
|
|
730
|
+
const seen = /* @__PURE__ */ new Set();
|
|
731
|
+
for (const request of ctx.requests) {
|
|
732
|
+
if (!request.responseHeaders) continue;
|
|
733
|
+
const origin = request.responseHeaders["access-control-allow-origin"];
|
|
734
|
+
const creds = request.responseHeaders["access-control-allow-credentials"];
|
|
735
|
+
if (origin !== "*" || creds !== "true") continue;
|
|
736
|
+
const ep = `${request.method} ${request.path}`;
|
|
737
|
+
if (seen.has(ep)) continue;
|
|
738
|
+
seen.add(ep);
|
|
739
|
+
findings.push({
|
|
740
|
+
severity: "warning",
|
|
741
|
+
rule: "cors-credentials",
|
|
742
|
+
title: "CORS Credentials with Wildcard",
|
|
743
|
+
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
626
744
|
hint: this.hint,
|
|
627
745
|
endpoint: ep,
|
|
628
746
|
count: 1
|
|
629
|
-
};
|
|
630
|
-
seen.set(dedupKey, finding);
|
|
631
|
-
findings.push(finding);
|
|
747
|
+
});
|
|
632
748
|
}
|
|
633
749
|
return findings;
|
|
634
750
|
}
|
|
635
751
|
};
|
|
636
752
|
|
|
637
|
-
// src/analysis/rules/
|
|
753
|
+
// src/analysis/rules/data-rules.ts
|
|
638
754
|
var stackTraceLeakRule = {
|
|
639
755
|
id: "stack-trace-leak",
|
|
640
756
|
severity: "critical",
|
|
641
757
|
name: "Stack Trace Leaked to Client",
|
|
642
758
|
hint: RULE_HINTS["stack-trace-leak"],
|
|
643
759
|
check(ctx) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
660
|
-
hint: this.hint,
|
|
661
|
-
endpoint: ep,
|
|
662
|
-
count: 1
|
|
760
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
761
|
+
if (!request.responseBody) return null;
|
|
762
|
+
if (!STACK_TRACE_RE.test(request.responseBody)) return null;
|
|
763
|
+
const ep = `${request.method} ${request.path}`;
|
|
764
|
+
return {
|
|
765
|
+
key: ep,
|
|
766
|
+
finding: {
|
|
767
|
+
severity: "critical",
|
|
768
|
+
rule: "stack-trace-leak",
|
|
769
|
+
title: "Stack Trace Leaked to Client",
|
|
770
|
+
desc: `${ep} \u2014 response exposes internal stack trace`,
|
|
771
|
+
hint: this.hint,
|
|
772
|
+
endpoint: ep,
|
|
773
|
+
count: 1
|
|
774
|
+
}
|
|
663
775
|
};
|
|
664
|
-
|
|
665
|
-
findings.push(finding);
|
|
666
|
-
}
|
|
667
|
-
return findings;
|
|
776
|
+
});
|
|
668
777
|
}
|
|
669
778
|
};
|
|
670
|
-
|
|
671
|
-
// src/analysis/rules/error-info-leak.ts
|
|
672
779
|
var CRITICAL_PATTERNS = [
|
|
673
780
|
{ re: DB_CONN_RE, label: "database connection string" },
|
|
674
781
|
{ re: SQL_FRAGMENT_RE, label: "SQL query fragment" },
|
|
@@ -680,90 +787,34 @@ var errorInfoLeakRule = {
|
|
|
680
787
|
name: "Sensitive Data in Error Response",
|
|
681
788
|
hint: RULE_HINTS["error-info-leak"],
|
|
682
789
|
check(ctx) {
|
|
683
|
-
const
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
if (
|
|
687
|
-
if (
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const dedupKey = `${ep}:${p.label}`;
|
|
693
|
-
const existing = seen.get(dedupKey);
|
|
694
|
-
if (existing) {
|
|
695
|
-
existing.count++;
|
|
696
|
-
continue;
|
|
790
|
+
const entries = [];
|
|
791
|
+
for (const request of ctx.requests) {
|
|
792
|
+
if (request.statusCode < 400) continue;
|
|
793
|
+
if (!request.responseBody) continue;
|
|
794
|
+
if (request.responseHeaders["x-nextjs-error"] || request.responseHeaders["x-nextjs-matched-path"]) continue;
|
|
795
|
+
const ep = `${request.method} ${request.path}`;
|
|
796
|
+
for (const pattern of CRITICAL_PATTERNS) {
|
|
797
|
+
if (pattern.re.test(request.responseBody)) {
|
|
798
|
+
entries.push({ ep, pattern, body: request.responseBody });
|
|
697
799
|
}
|
|
698
|
-
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return deduplicateFindings(entries, ({ ep, pattern }) => {
|
|
803
|
+
return {
|
|
804
|
+
key: `${ep}:${pattern.label}`,
|
|
805
|
+
finding: {
|
|
699
806
|
severity: "critical",
|
|
700
807
|
rule: "error-info-leak",
|
|
701
808
|
title: "Sensitive Data in Error Response",
|
|
702
|
-
desc: `${ep} \u2014 error response exposes ${
|
|
809
|
+
desc: `${ep} \u2014 error response exposes ${pattern.label}`,
|
|
703
810
|
hint: this.hint,
|
|
704
811
|
endpoint: ep,
|
|
705
812
|
count: 1
|
|
706
|
-
};
|
|
707
|
-
seen.set(dedupKey, finding);
|
|
708
|
-
findings.push(finding);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
return findings;
|
|
712
|
-
}
|
|
713
|
-
};
|
|
714
|
-
|
|
715
|
-
// src/analysis/rules/insecure-cookie.ts
|
|
716
|
-
function isFrameworkResponse(r) {
|
|
717
|
-
if (isRedirect(r.statusCode)) return true;
|
|
718
|
-
if (r.path?.startsWith("/__")) return true;
|
|
719
|
-
if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
|
|
720
|
-
return false;
|
|
721
|
-
}
|
|
722
|
-
var insecureCookieRule = {
|
|
723
|
-
id: "insecure-cookie",
|
|
724
|
-
severity: "warning",
|
|
725
|
-
name: "Insecure Cookie",
|
|
726
|
-
hint: RULE_HINTS["insecure-cookie"],
|
|
727
|
-
check(ctx) {
|
|
728
|
-
const findings = [];
|
|
729
|
-
const seen = /* @__PURE__ */ new Map();
|
|
730
|
-
for (const r of ctx.requests) {
|
|
731
|
-
if (!r.responseHeaders) continue;
|
|
732
|
-
if (isFrameworkResponse(r)) continue;
|
|
733
|
-
const setCookie = r.responseHeaders["set-cookie"];
|
|
734
|
-
if (!setCookie) continue;
|
|
735
|
-
const cookies = setCookie.split(/,(?=\s*[A-Za-z0-9_\-]+=)/);
|
|
736
|
-
for (const cookie of cookies) {
|
|
737
|
-
const cookieName = cookie.trim().split("=")[0].trim();
|
|
738
|
-
const lower = cookie.toLowerCase();
|
|
739
|
-
const issues = [];
|
|
740
|
-
if (!lower.includes("httponly")) issues.push("HttpOnly");
|
|
741
|
-
if (!lower.includes("samesite")) issues.push("SameSite");
|
|
742
|
-
if (issues.length === 0) continue;
|
|
743
|
-
const dedupKey = `${cookieName}:${issues.join(",")}`;
|
|
744
|
-
const existing = seen.get(dedupKey);
|
|
745
|
-
if (existing) {
|
|
746
|
-
existing.count++;
|
|
747
|
-
continue;
|
|
748
813
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
rule: "insecure-cookie",
|
|
752
|
-
title: "Insecure Cookie",
|
|
753
|
-
desc: `${cookieName} \u2014 missing ${issues.join(", ")} flag${issues.length > 1 ? "s" : ""}`,
|
|
754
|
-
hint: this.hint,
|
|
755
|
-
endpoint: cookieName,
|
|
756
|
-
count: 1
|
|
757
|
-
};
|
|
758
|
-
seen.set(dedupKey, finding);
|
|
759
|
-
findings.push(finding);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
return findings;
|
|
814
|
+
};
|
|
815
|
+
});
|
|
763
816
|
}
|
|
764
817
|
};
|
|
765
|
-
|
|
766
|
-
// src/analysis/rules/sensitive-logs.ts
|
|
767
818
|
var sensitiveLogsRule = {
|
|
768
819
|
id: "sensitive-logs",
|
|
769
820
|
severity: "warning",
|
|
@@ -788,58 +839,13 @@ var sensitiveLogsRule = {
|
|
|
788
839
|
}];
|
|
789
840
|
}
|
|
790
841
|
};
|
|
791
|
-
|
|
792
|
-
// src/analysis/rules/cors-credentials.ts
|
|
793
|
-
var corsCredentialsRule = {
|
|
794
|
-
id: "cors-credentials",
|
|
795
|
-
severity: "warning",
|
|
796
|
-
name: "CORS Credentials with Wildcard",
|
|
797
|
-
hint: RULE_HINTS["cors-credentials"],
|
|
798
|
-
check(ctx) {
|
|
799
|
-
const findings = [];
|
|
800
|
-
const seen = /* @__PURE__ */ new Set();
|
|
801
|
-
for (const r of ctx.requests) {
|
|
802
|
-
if (!r.responseHeaders) continue;
|
|
803
|
-
const origin = r.responseHeaders["access-control-allow-origin"];
|
|
804
|
-
const creds = r.responseHeaders["access-control-allow-credentials"];
|
|
805
|
-
if (origin !== "*" || creds !== "true") continue;
|
|
806
|
-
const ep = `${r.method} ${r.path}`;
|
|
807
|
-
if (seen.has(ep)) continue;
|
|
808
|
-
seen.add(ep);
|
|
809
|
-
findings.push({
|
|
810
|
-
severity: "warning",
|
|
811
|
-
rule: "cors-credentials",
|
|
812
|
-
title: "CORS Credentials with Wildcard",
|
|
813
|
-
desc: `${ep} \u2014 credentials:true with origin:* (browser will reject)`,
|
|
814
|
-
hint: this.hint,
|
|
815
|
-
endpoint: ep,
|
|
816
|
-
count: 1
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
return findings;
|
|
820
|
-
}
|
|
821
|
-
};
|
|
822
|
-
|
|
823
|
-
// src/analysis/rules/response-pii-leak.ts
|
|
824
842
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
|
|
825
|
-
function findEmails(obj
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
emails.push(...findEmails(obj[i], depth + 1));
|
|
832
|
-
}
|
|
833
|
-
return emails;
|
|
834
|
-
}
|
|
835
|
-
for (const v of Object.values(obj)) {
|
|
836
|
-
if (typeof v === "string" && EMAIL_RE.test(v)) {
|
|
837
|
-
emails.push(v);
|
|
838
|
-
} else if (typeof v === "object" && v !== null) {
|
|
839
|
-
emails.push(...findEmails(v, depth + 1));
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
return emails;
|
|
843
|
+
function findEmails(obj) {
|
|
844
|
+
return collectFromObject(
|
|
845
|
+
obj,
|
|
846
|
+
(_key, val) => typeof val === "string" && EMAIL_RE.test(val) ? val : null,
|
|
847
|
+
{ arrayLimit: PII_SCAN_ARRAY_LIMIT }
|
|
848
|
+
);
|
|
843
849
|
}
|
|
844
850
|
function topLevelFieldCount(obj) {
|
|
845
851
|
if (Array.isArray(obj)) {
|
|
@@ -924,35 +930,28 @@ var responsePiiLeakRule = {
|
|
|
924
930
|
name: "PII Leak in Response",
|
|
925
931
|
hint: RULE_HINTS["response-pii-leak"],
|
|
926
932
|
check(ctx) {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
if (
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
desc: `${ep} \u2014 exposes PII in response`,
|
|
948
|
-
hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
|
|
949
|
-
endpoint: ep,
|
|
950
|
-
count: 1
|
|
933
|
+
return deduplicateFindings(ctx.requests, (request) => {
|
|
934
|
+
if (isErrorStatus(request.statusCode)) return null;
|
|
935
|
+
if (SELF_SERVICE_PATH.test(request.path)) return null;
|
|
936
|
+
const resJson = ctx.parsedBodies.response.get(request.id);
|
|
937
|
+
if (!resJson) return null;
|
|
938
|
+
const reqJson = ctx.parsedBodies.request.get(request.id) ?? null;
|
|
939
|
+
const detection = detectPII(request.method, reqJson, resJson);
|
|
940
|
+
if (!detection) return null;
|
|
941
|
+
const ep = `${request.method} ${request.path}`;
|
|
942
|
+
return {
|
|
943
|
+
key: ep,
|
|
944
|
+
finding: {
|
|
945
|
+
severity: "warning",
|
|
946
|
+
rule: "response-pii-leak",
|
|
947
|
+
title: "PII Leak in Response",
|
|
948
|
+
desc: `${ep} \u2014 exposes PII in response`,
|
|
949
|
+
hint: `Detection: ${REASON_LABELS[detection.reason]}. ${this.hint}`,
|
|
950
|
+
endpoint: ep,
|
|
951
|
+
count: 1
|
|
952
|
+
}
|
|
951
953
|
};
|
|
952
|
-
|
|
953
|
-
findings.push(finding);
|
|
954
|
-
}
|
|
955
|
-
return findings;
|
|
954
|
+
});
|
|
956
955
|
}
|
|
957
956
|
};
|
|
958
957
|
|
|
@@ -960,14 +959,14 @@ var responsePiiLeakRule = {
|
|
|
960
959
|
function buildBodyCache(requests) {
|
|
961
960
|
const response = /* @__PURE__ */ new Map();
|
|
962
961
|
const request = /* @__PURE__ */ new Map();
|
|
963
|
-
for (const
|
|
964
|
-
if (
|
|
965
|
-
const parsed = tryParseJson(
|
|
966
|
-
if (parsed != null) response.set(
|
|
962
|
+
for (const req of requests) {
|
|
963
|
+
if (req.responseBody) {
|
|
964
|
+
const parsed = tryParseJson(req.responseBody);
|
|
965
|
+
if (parsed != null) response.set(req.id, parsed);
|
|
967
966
|
}
|
|
968
|
-
if (
|
|
969
|
-
const parsed = tryParseJson(
|
|
970
|
-
if (parsed != null) request.set(
|
|
967
|
+
if (req.requestBody) {
|
|
968
|
+
const parsed = tryParseJson(req.requestBody);
|
|
969
|
+
if (parsed != null) request.set(req.id, parsed);
|
|
971
970
|
}
|
|
972
971
|
}
|
|
973
972
|
return { response, request };
|
|
@@ -988,7 +987,8 @@ var SecurityScanner = class {
|
|
|
988
987
|
for (const rule of this.rules) {
|
|
989
988
|
try {
|
|
990
989
|
findings.push(...rule.check(ctx));
|
|
991
|
-
} catch {
|
|
990
|
+
} catch (e) {
|
|
991
|
+
brakitDebug(`rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
992
992
|
}
|
|
993
993
|
}
|
|
994
994
|
return findings;
|
|
@@ -1027,7 +1027,7 @@ var SubscriptionBag = class {
|
|
|
1027
1027
|
// src/analysis/group.ts
|
|
1028
1028
|
import { randomUUID } from "crypto";
|
|
1029
1029
|
|
|
1030
|
-
// src/constants/
|
|
1030
|
+
// src/constants/labels.ts
|
|
1031
1031
|
var DASHBOARD_PREFIX = "/__brakit";
|
|
1032
1032
|
var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
|
|
1033
1033
|
var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
|
|
@@ -1059,13 +1059,40 @@ var VALID_TABS_TUPLE = [
|
|
|
1059
1059
|
];
|
|
1060
1060
|
var VALID_TABS = new Set(VALID_TABS_TUPLE);
|
|
1061
1061
|
|
|
1062
|
-
// src/constants/
|
|
1062
|
+
// src/constants/features.ts
|
|
1063
1063
|
var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
|
|
1064
1064
|
|
|
1065
|
+
// src/utils/endpoint.ts
|
|
1066
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1067
|
+
var NUMERIC_ID_RE = /^\d+$/;
|
|
1068
|
+
var HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
|
|
1069
|
+
var ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
|
|
1070
|
+
function isDynamicSegment(segment) {
|
|
1071
|
+
return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
|
|
1072
|
+
}
|
|
1073
|
+
var DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
|
|
1074
|
+
function normalizePath(path) {
|
|
1075
|
+
const qIdx = path.indexOf("?");
|
|
1076
|
+
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1077
|
+
return pathname.split("/").map((seg) => seg && isDynamicSegment(seg) ? DYNAMIC_SEGMENT_PLACEHOLDER : seg).join("/");
|
|
1078
|
+
}
|
|
1079
|
+
function getEndpointKey(method, path) {
|
|
1080
|
+
return `${method} ${normalizePath(path)}`;
|
|
1081
|
+
}
|
|
1082
|
+
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1083
|
+
function extractEndpointFromDesc(desc) {
|
|
1084
|
+
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1085
|
+
}
|
|
1086
|
+
function stripQueryString(path) {
|
|
1087
|
+
const i = path.indexOf("?");
|
|
1088
|
+
return i === -1 ? path : path.slice(0, i);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1065
1091
|
// src/analysis/categorize.ts
|
|
1066
1092
|
function detectCategory(req) {
|
|
1067
1093
|
const { method, url, statusCode, responseHeaders } = req;
|
|
1068
1094
|
if (req.isStatic) return "static";
|
|
1095
|
+
if (req.isHealthCheck) return "health-check";
|
|
1069
1096
|
if (statusCode === 307 && (url.includes("__clerk_handshake") || url.includes("__clerk_db_jwt"))) {
|
|
1070
1097
|
return "auth-handshake";
|
|
1071
1098
|
}
|
|
@@ -1217,20 +1244,42 @@ function capitalize(s) {
|
|
|
1217
1244
|
}
|
|
1218
1245
|
|
|
1219
1246
|
// src/analysis/transforms.ts
|
|
1220
|
-
|
|
1247
|
+
var DUPLICATE_CATEGORIES = /* @__PURE__ */ new Set(["data-fetch", "auth-check"]);
|
|
1248
|
+
function isDuplicateCandidate(req) {
|
|
1249
|
+
return DUPLICATE_CATEGORIES.has(req.category);
|
|
1250
|
+
}
|
|
1251
|
+
function buildRequestKey(req) {
|
|
1252
|
+
return `${req.method} ${stripQueryString(getEffectivePath(req))}`;
|
|
1253
|
+
}
|
|
1254
|
+
function isStrictModePattern(requests, counts) {
|
|
1255
|
+
if (counts.size === 0 || ![...counts.values()].every((c) => c === 2)) {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
const firstByKey = /* @__PURE__ */ new Map();
|
|
1259
|
+
for (const req of requests) {
|
|
1260
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1261
|
+
const key = buildRequestKey(req);
|
|
1262
|
+
const first = firstByKey.get(key);
|
|
1263
|
+
if (!first) {
|
|
1264
|
+
firstByKey.set(key, req);
|
|
1265
|
+
} else if (Math.abs(req.startedAt - first.startedAt) > STRICT_MODE_MAX_GAP_MS) {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
function flagDuplicateRequests(requests) {
|
|
1221
1272
|
const counts = /* @__PURE__ */ new Map();
|
|
1222
1273
|
for (const req of requests) {
|
|
1223
|
-
if (req
|
|
1224
|
-
|
|
1225
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1274
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1275
|
+
const key = buildRequestKey(req);
|
|
1226
1276
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1227
1277
|
}
|
|
1228
|
-
const isStrictMode =
|
|
1278
|
+
const isStrictMode = isStrictModePattern(requests, counts);
|
|
1229
1279
|
const seen = /* @__PURE__ */ new Set();
|
|
1230
1280
|
for (const req of requests) {
|
|
1231
|
-
if (req
|
|
1232
|
-
|
|
1233
|
-
const key = `${req.method} ${getEffectivePath(req).split("?")[0]}`;
|
|
1281
|
+
if (!isDuplicateCandidate(req)) continue;
|
|
1282
|
+
const key = buildRequestKey(req);
|
|
1234
1283
|
if (seen.has(key)) {
|
|
1235
1284
|
if (isStrictMode) {
|
|
1236
1285
|
req.isStrictModeDupe = true;
|
|
@@ -1242,20 +1291,20 @@ function markDuplicates(requests) {
|
|
|
1242
1291
|
}
|
|
1243
1292
|
}
|
|
1244
1293
|
}
|
|
1245
|
-
function
|
|
1294
|
+
function mergePollingSequences(requests) {
|
|
1246
1295
|
const result = [];
|
|
1247
1296
|
let i = 0;
|
|
1248
1297
|
while (i < requests.length) {
|
|
1249
1298
|
const current = requests[i];
|
|
1250
|
-
const currentEffective = getEffectivePath(current)
|
|
1299
|
+
const currentEffective = stripQueryString(getEffectivePath(current));
|
|
1251
1300
|
if (current.method === "GET" && current.category === "data-fetch") {
|
|
1252
|
-
let
|
|
1253
|
-
while (
|
|
1254
|
-
|
|
1301
|
+
let nextIndex = i + 1;
|
|
1302
|
+
while (nextIndex < requests.length && requests[nextIndex].method === "GET" && stripQueryString(getEffectivePath(requests[nextIndex])) === currentEffective) {
|
|
1303
|
+
nextIndex++;
|
|
1255
1304
|
}
|
|
1256
|
-
const count =
|
|
1305
|
+
const count = nextIndex - i;
|
|
1257
1306
|
if (count >= MIN_POLLING_SEQUENCE) {
|
|
1258
|
-
const last = requests[
|
|
1307
|
+
const last = requests[nextIndex - 1];
|
|
1259
1308
|
const pollingDuration = last.startedAt + last.durationMs - current.startedAt;
|
|
1260
1309
|
const endpointName = prettifyEndpoint(currentEffective);
|
|
1261
1310
|
result.push({
|
|
@@ -1266,7 +1315,7 @@ function collapsePolling(requests) {
|
|
|
1266
1315
|
pollingDurationMs: pollingDuration,
|
|
1267
1316
|
isDuplicate: false
|
|
1268
1317
|
});
|
|
1269
|
-
i =
|
|
1318
|
+
i = nextIndex;
|
|
1270
1319
|
continue;
|
|
1271
1320
|
}
|
|
1272
1321
|
}
|
|
@@ -1279,18 +1328,18 @@ function formatDurationLabel(ms) {
|
|
|
1279
1328
|
if (ms < 1e3) return `${ms}ms`;
|
|
1280
1329
|
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1281
1330
|
}
|
|
1282
|
-
function
|
|
1331
|
+
function collectRequestWarnings(requests) {
|
|
1283
1332
|
const warnings = [];
|
|
1284
1333
|
const duplicateCount = requests.filter((r) => r.isDuplicate).length;
|
|
1285
1334
|
if (duplicateCount > 0) {
|
|
1286
1335
|
const unique = new Set(
|
|
1287
|
-
requests.filter((r) => r.isDuplicate).map((r) =>
|
|
1336
|
+
requests.filter((r) => r.isDuplicate).map((r) => buildRequestKey(r))
|
|
1288
1337
|
);
|
|
1289
1338
|
const endpoints = unique.size;
|
|
1290
1339
|
const sameData = requests.filter((r) => r.isDuplicate).every((r) => {
|
|
1291
|
-
const key =
|
|
1340
|
+
const key = buildRequestKey(r);
|
|
1292
1341
|
const first = requests.find(
|
|
1293
|
-
(o) => !o.isDuplicate &&
|
|
1342
|
+
(o) => !o.isDuplicate && buildRequestKey(o) === key
|
|
1294
1343
|
);
|
|
1295
1344
|
return first && first.responseBody === r.responseBody;
|
|
1296
1345
|
});
|
|
@@ -1313,6 +1362,14 @@ function detectWarnings(requests) {
|
|
|
1313
1362
|
}
|
|
1314
1363
|
|
|
1315
1364
|
// src/analysis/group.ts
|
|
1365
|
+
function shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, startedAt) {
|
|
1366
|
+
if (currentRequests.length === 0) return false;
|
|
1367
|
+
const sourcePage = labeled.sourcePage;
|
|
1368
|
+
const isNewPage = sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1369
|
+
const isTimeGap = startedAt - lastEndTime > FLOW_GAP_MS;
|
|
1370
|
+
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1371
|
+
return isNewPage || isTimeGap || isPageLoad;
|
|
1372
|
+
}
|
|
1316
1373
|
function groupRequestsIntoFlows(requests) {
|
|
1317
1374
|
if (requests.length === 0) return [];
|
|
1318
1375
|
const flows = [];
|
|
@@ -1323,17 +1380,12 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1323
1380
|
if (req.path.startsWith(DASHBOARD_PREFIX)) continue;
|
|
1324
1381
|
const labeled = labelRequest(req);
|
|
1325
1382
|
if (labeled.category === "static") continue;
|
|
1326
|
-
|
|
1327
|
-
const gap = currentRequests.length > 0 ? req.startedAt - lastEndTime : 0;
|
|
1328
|
-
const isNewPage = currentRequests.length > 0 && sourcePage !== void 0 && currentSourcePage !== void 0 && sourcePage !== currentSourcePage;
|
|
1329
|
-
const isPageLoad = labeled.category === "page-load" || labeled.category === "navigation";
|
|
1330
|
-
const isTimeGap = currentRequests.length > 0 && gap > FLOW_GAP_MS;
|
|
1331
|
-
if (currentRequests.length > 0 && (isNewPage || isTimeGap || isPageLoad)) {
|
|
1383
|
+
if (shouldStartNewFlow(labeled, currentRequests, lastEndTime, currentSourcePage, req.startedAt)) {
|
|
1332
1384
|
flows.push(buildFlow(currentRequests));
|
|
1333
1385
|
currentRequests = [];
|
|
1334
1386
|
}
|
|
1335
1387
|
currentRequests.push(labeled);
|
|
1336
|
-
currentSourcePage = sourcePage ?? currentSourcePage;
|
|
1388
|
+
currentSourcePage = labeled.sourcePage ?? currentSourcePage;
|
|
1337
1389
|
lastEndTime = Math.max(lastEndTime, req.startedAt + req.durationMs);
|
|
1338
1390
|
}
|
|
1339
1391
|
if (currentRequests.length > 0) {
|
|
@@ -1342,8 +1394,8 @@ function groupRequestsIntoFlows(requests) {
|
|
|
1342
1394
|
return flows;
|
|
1343
1395
|
}
|
|
1344
1396
|
function buildFlow(rawRequests) {
|
|
1345
|
-
|
|
1346
|
-
const requests =
|
|
1397
|
+
flagDuplicateRequests(rawRequests);
|
|
1398
|
+
const requests = mergePollingSequences(rawRequests);
|
|
1347
1399
|
const first = requests[0];
|
|
1348
1400
|
const startTime = first.startedAt;
|
|
1349
1401
|
const endTime = Math.max(
|
|
@@ -1362,7 +1414,7 @@ function buildFlow(rawRequests) {
|
|
|
1362
1414
|
startTime,
|
|
1363
1415
|
totalDurationMs: Math.round(endTime - startTime),
|
|
1364
1416
|
hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
|
|
1365
|
-
warnings:
|
|
1417
|
+
warnings: collectRequestWarnings(rawRequests),
|
|
1366
1418
|
sourcePage,
|
|
1367
1419
|
redundancyPct
|
|
1368
1420
|
};
|
|
@@ -1374,20 +1426,20 @@ function getDominantSourcePage(requests) {
|
|
|
1374
1426
|
counts.set(req.sourcePage, (counts.get(req.sourcePage) ?? 0) + 1);
|
|
1375
1427
|
}
|
|
1376
1428
|
}
|
|
1377
|
-
let
|
|
1378
|
-
let
|
|
1429
|
+
let mostCommonPage = "";
|
|
1430
|
+
let highestCount = 0;
|
|
1379
1431
|
for (const [page, count] of counts) {
|
|
1380
|
-
if (count >
|
|
1381
|
-
|
|
1382
|
-
|
|
1432
|
+
if (count > highestCount) {
|
|
1433
|
+
mostCommonPage = page;
|
|
1434
|
+
highestCount = count;
|
|
1383
1435
|
}
|
|
1384
1436
|
}
|
|
1385
|
-
return
|
|
1437
|
+
return mostCommonPage || (requests[0]?.path ? stripQueryString(requests[0].path) : "") || "/";
|
|
1386
1438
|
}
|
|
1387
1439
|
function deriveFlowLabel(requests, sourcePage) {
|
|
1388
1440
|
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];
|
|
1389
1441
|
if (trigger.category === "page-load" || trigger.category === "navigation") {
|
|
1390
|
-
const pageName = prettifyPageName(trigger.path
|
|
1442
|
+
const pageName = prettifyPageName(stripQueryString(trigger.path));
|
|
1391
1443
|
return `${pageName} Page`;
|
|
1392
1444
|
}
|
|
1393
1445
|
if (trigger.category === "api-call") {
|
|
@@ -1412,67 +1464,23 @@ function deriveFlowLabel(requests, sourcePage) {
|
|
|
1412
1464
|
return trigger.label;
|
|
1413
1465
|
}
|
|
1414
1466
|
|
|
1415
|
-
// src/utils/collections.ts
|
|
1416
|
-
function groupBy(items, keyFn) {
|
|
1417
|
-
const map = /* @__PURE__ */ new Map();
|
|
1418
|
-
for (const item of items) {
|
|
1419
|
-
const key = keyFn(item);
|
|
1420
|
-
if (key == null) continue;
|
|
1421
|
-
let arr = map.get(key);
|
|
1422
|
-
if (!arr) {
|
|
1423
|
-
arr = [];
|
|
1424
|
-
map.set(key, arr);
|
|
1425
|
-
}
|
|
1426
|
-
arr.push(item);
|
|
1427
|
-
}
|
|
1428
|
-
return map;
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// src/utils/endpoint.ts
|
|
1432
|
-
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;
|
|
1433
|
-
function normalizePath(path) {
|
|
1434
|
-
const qIdx = path.indexOf("?");
|
|
1435
|
-
const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
|
|
1436
|
-
return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
|
|
1437
|
-
}
|
|
1438
|
-
function getEndpointKey(method, path) {
|
|
1439
|
-
return `${method} ${normalizePath(path)}`;
|
|
1440
|
-
}
|
|
1441
|
-
var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
|
|
1442
|
-
function extractEndpointFromDesc(desc) {
|
|
1443
|
-
return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
1467
|
// src/instrument/adapters/normalize.ts
|
|
1468
|
+
var VALID_OPS = /* @__PURE__ */ new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]);
|
|
1469
|
+
var TABLE_RE = /(?:FROM|INTO|UPDATE)\s+(?:"?\w+"?\.)?"?(\w+)"?/i;
|
|
1447
1470
|
function normalizeSQL(sql) {
|
|
1448
1471
|
if (!sql) return { op: "OTHER", table: "" };
|
|
1449
1472
|
const trimmed = sql.trim();
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
case "SELECT":
|
|
1459
|
-
return { op: "SELECT", table };
|
|
1460
|
-
case "INSERT":
|
|
1461
|
-
return { op: "INSERT", table };
|
|
1462
|
-
case "UPDATE":
|
|
1463
|
-
return { op: "UPDATE", table };
|
|
1464
|
-
case "DELETE":
|
|
1465
|
-
return { op: "DELETE", table };
|
|
1466
|
-
default:
|
|
1467
|
-
return { op: "OTHER", table };
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1473
|
+
const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
|
|
1474
|
+
const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
|
|
1475
|
+
const table = trimmed.match(TABLE_RE)?.[1] ?? "";
|
|
1476
|
+
return { op, table };
|
|
1477
|
+
}
|
|
1478
|
+
var SQL_PARAM_MARKER = /\$\d+/g;
|
|
1479
|
+
var SQL_STRING_LITERAL = /'[^']*'/g;
|
|
1480
|
+
var SQL_NUMBER_LITERAL = /\b\d+(\.\d+)?\b/g;
|
|
1470
1481
|
function normalizeQueryParams(sql) {
|
|
1471
1482
|
if (!sql) return null;
|
|
1472
|
-
|
|
1473
|
-
n = n.replace(/\b\d+(\.\d+)?\b/g, "?");
|
|
1474
|
-
n = n.replace(/\$\d+/g, "?");
|
|
1475
|
-
return n;
|
|
1483
|
+
return sql.replace(SQL_PARAM_MARKER, "?").replace(SQL_STRING_LITERAL, "?").replace(SQL_NUMBER_LITERAL, "?");
|
|
1476
1484
|
}
|
|
1477
1485
|
|
|
1478
1486
|
// src/analysis/insights/query-helpers.ts
|
|
@@ -1489,7 +1497,7 @@ function getQueryInfo(q) {
|
|
|
1489
1497
|
}
|
|
1490
1498
|
|
|
1491
1499
|
// src/analysis/insights/prepare.ts
|
|
1492
|
-
function
|
|
1500
|
+
function emptyEndpointGroup() {
|
|
1493
1501
|
return {
|
|
1494
1502
|
total: 0,
|
|
1495
1503
|
errors: 0,
|
|
@@ -1501,16 +1509,12 @@ function createEndpointGroup() {
|
|
|
1501
1509
|
queryShapeDurations: /* @__PURE__ */ new Map()
|
|
1502
1510
|
};
|
|
1503
1511
|
}
|
|
1504
|
-
function
|
|
1512
|
+
function keepRecentPerEndpoint(requests) {
|
|
1505
1513
|
const byEndpoint = /* @__PURE__ */ new Map();
|
|
1506
|
-
for (const
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
list = [];
|
|
1511
|
-
byEndpoint.set(ep, list);
|
|
1512
|
-
}
|
|
1513
|
-
list.push(r);
|
|
1514
|
+
for (const request of requests) {
|
|
1515
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1516
|
+
const list = getOrCreate(byEndpoint, endpointKey, () => []);
|
|
1517
|
+
list.push(request);
|
|
1514
1518
|
}
|
|
1515
1519
|
const windowed = [];
|
|
1516
1520
|
for (const [, reqs] of byEndpoint) {
|
|
@@ -1518,54 +1522,67 @@ function windowByEndpoint(requests) {
|
|
|
1518
1522
|
}
|
|
1519
1523
|
return windowed;
|
|
1520
1524
|
}
|
|
1525
|
+
function filterUserRequests(requests) {
|
|
1526
|
+
return requests.filter(
|
|
1527
|
+
(request) => !request.isStatic && !request.isHealthCheck && (!request.path || !request.path.startsWith(DASHBOARD_PREFIX))
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1521
1530
|
function extractActiveEndpoints(requests) {
|
|
1522
1531
|
const endpoints = /* @__PURE__ */ new Set();
|
|
1523
|
-
for (const
|
|
1524
|
-
|
|
1525
|
-
endpoints.add(getEndpointKey(r.method, r.path));
|
|
1526
|
-
}
|
|
1532
|
+
for (const request of filterUserRequests(requests)) {
|
|
1533
|
+
endpoints.add(getEndpointKey(request.method, request.path));
|
|
1527
1534
|
}
|
|
1528
1535
|
return endpoints;
|
|
1529
1536
|
}
|
|
1530
|
-
function
|
|
1531
|
-
const nonStatic = ctx.requests.filter(
|
|
1532
|
-
(r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
|
|
1533
|
-
);
|
|
1534
|
-
const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
|
|
1535
|
-
const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
|
|
1536
|
-
const reqById = new Map(nonStatic.map((r) => [r.id, r]));
|
|
1537
|
-
const recent = windowByEndpoint(nonStatic);
|
|
1537
|
+
function aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq) {
|
|
1538
1538
|
const endpointGroups = /* @__PURE__ */ new Map();
|
|
1539
|
-
for (const
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1539
|
+
for (const request of recent) {
|
|
1540
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1541
|
+
const group = getOrCreate(endpointGroups, endpointKey, emptyEndpointGroup);
|
|
1542
|
+
group.total++;
|
|
1543
|
+
if (isErrorStatus(request.statusCode)) group.errors++;
|
|
1544
|
+
group.totalDuration += request.durationMs;
|
|
1545
|
+
group.totalSize += request.responseSize ?? 0;
|
|
1546
|
+
const reqQueries = queriesByReq.get(request.id) ?? [];
|
|
1547
|
+
group.queryCount += reqQueries.length;
|
|
1548
|
+
for (const query of reqQueries) {
|
|
1549
|
+
group.totalQueryTimeMs += query.durationMs;
|
|
1550
|
+
const shape = getQueryShape(query);
|
|
1551
|
+
const info = getQueryInfo(query);
|
|
1552
|
+
const shapeDuration = getOrCreate(group.queryShapeDurations, shape, () => ({
|
|
1553
|
+
totalMs: 0,
|
|
1554
|
+
count: 0,
|
|
1555
|
+
label: info.op + (info.table ? ` ${info.table}` : "")
|
|
1556
|
+
}));
|
|
1557
|
+
shapeDuration.totalMs += query.durationMs;
|
|
1558
|
+
shapeDuration.count++;
|
|
1559
|
+
}
|
|
1560
|
+
const reqFetches = fetchesByReq.get(request.id) ?? [];
|
|
1561
|
+
for (const fetch of reqFetches) {
|
|
1562
|
+
group.totalFetchTimeMs += fetch.durationMs;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return endpointGroups;
|
|
1566
|
+
}
|
|
1567
|
+
function collectStrictModeDupeIds(ctx) {
|
|
1568
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1569
|
+
for (const flow of ctx.flows) {
|
|
1570
|
+
for (const req of flow.requests) {
|
|
1571
|
+
if (req.isStrictModeDupe) ids.add(req.id);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return ids;
|
|
1575
|
+
}
|
|
1576
|
+
function buildInsightContext(ctx) {
|
|
1577
|
+
const strictModeDupeIds = collectStrictModeDupeIds(ctx);
|
|
1578
|
+
const nonStatic = filterUserRequests(ctx.requests).filter((req) => !strictModeDupeIds.has(req.id));
|
|
1579
|
+
const filteredQueries = strictModeDupeIds.size > 0 ? ctx.queries.filter((q) => !q.parentRequestId || !strictModeDupeIds.has(q.parentRequestId)) : ctx.queries;
|
|
1580
|
+
const filteredFetches = strictModeDupeIds.size > 0 ? ctx.fetches.filter((f) => !f.parentRequestId || !strictModeDupeIds.has(f.parentRequestId)) : ctx.fetches;
|
|
1581
|
+
const queriesByReq = groupBy(filteredQueries, (query) => query.parentRequestId);
|
|
1582
|
+
const fetchesByReq = groupBy(filteredFetches, (fetch) => fetch.parentRequestId);
|
|
1583
|
+
const reqById = new Map(nonStatic.map((request) => [request.id, request]));
|
|
1584
|
+
const recent = keepRecentPerEndpoint(nonStatic);
|
|
1585
|
+
const endpointGroups = aggregateEndpointMetrics(recent, queriesByReq, fetchesByReq);
|
|
1569
1586
|
return {
|
|
1570
1587
|
...ctx,
|
|
1571
1588
|
nonStatic,
|
|
@@ -1586,12 +1603,13 @@ var InsightRunner = class {
|
|
|
1586
1603
|
this.rules.push(rule);
|
|
1587
1604
|
}
|
|
1588
1605
|
run(ctx) {
|
|
1589
|
-
const prepared =
|
|
1606
|
+
const prepared = buildInsightContext(ctx);
|
|
1590
1607
|
const insights = [];
|
|
1591
1608
|
for (const rule of this.rules) {
|
|
1592
1609
|
try {
|
|
1593
1610
|
insights.push(...rule.check(prepared));
|
|
1594
|
-
} catch {
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
brakitDebug(`insight rule ${rule.id} failed: ${getErrorMessage(e)}`);
|
|
1595
1613
|
}
|
|
1596
1614
|
}
|
|
1597
1615
|
insights.sort(
|
|
@@ -1601,7 +1619,7 @@ var InsightRunner = class {
|
|
|
1601
1619
|
}
|
|
1602
1620
|
};
|
|
1603
1621
|
|
|
1604
|
-
// src/analysis/insights/rules/
|
|
1622
|
+
// src/analysis/insights/rules/query-rules.ts
|
|
1605
1623
|
var n1Rule = {
|
|
1606
1624
|
id: "n1",
|
|
1607
1625
|
check(ctx) {
|
|
@@ -1612,15 +1630,15 @@ var n1Rule = {
|
|
|
1612
1630
|
if (!req) continue;
|
|
1613
1631
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1614
1632
|
const shapeGroups = /* @__PURE__ */ new Map();
|
|
1615
|
-
for (const
|
|
1616
|
-
const shape = getQueryShape(
|
|
1633
|
+
for (const query of reqQueries) {
|
|
1634
|
+
const shape = getQueryShape(query);
|
|
1617
1635
|
let group = shapeGroups.get(shape);
|
|
1618
1636
|
if (!group) {
|
|
1619
|
-
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first:
|
|
1637
|
+
group = { count: 0, distinctSql: /* @__PURE__ */ new Set(), first: query };
|
|
1620
1638
|
shapeGroups.set(shape, group);
|
|
1621
1639
|
}
|
|
1622
1640
|
group.count++;
|
|
1623
|
-
group.distinctSql.add(
|
|
1641
|
+
group.distinctSql.add(query.sql ?? shape);
|
|
1624
1642
|
}
|
|
1625
1643
|
for (const [, sg] of shapeGroups) {
|
|
1626
1644
|
if (sg.count <= N1_QUERY_THRESHOLD || sg.distinctSql.size <= 1) continue;
|
|
@@ -1641,57 +1659,6 @@ var n1Rule = {
|
|
|
1641
1659
|
return insights;
|
|
1642
1660
|
}
|
|
1643
1661
|
};
|
|
1644
|
-
|
|
1645
|
-
// src/analysis/insights/rules/cross-endpoint.ts
|
|
1646
|
-
var crossEndpointRule = {
|
|
1647
|
-
id: "cross-endpoint",
|
|
1648
|
-
check(ctx) {
|
|
1649
|
-
const insights = [];
|
|
1650
|
-
const queryMap = /* @__PURE__ */ new Map();
|
|
1651
|
-
const allEndpoints = /* @__PURE__ */ new Set();
|
|
1652
|
-
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
1653
|
-
const req = ctx.reqById.get(reqId);
|
|
1654
|
-
if (!req) continue;
|
|
1655
|
-
const endpoint = getEndpointKey(req.method, req.path);
|
|
1656
|
-
allEndpoints.add(endpoint);
|
|
1657
|
-
const seenInReq = /* @__PURE__ */ new Set();
|
|
1658
|
-
for (const q of reqQueries) {
|
|
1659
|
-
const shape = getQueryShape(q);
|
|
1660
|
-
let entry = queryMap.get(shape);
|
|
1661
|
-
if (!entry) {
|
|
1662
|
-
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: q };
|
|
1663
|
-
queryMap.set(shape, entry);
|
|
1664
|
-
}
|
|
1665
|
-
entry.count++;
|
|
1666
|
-
if (!seenInReq.has(shape)) {
|
|
1667
|
-
seenInReq.add(shape);
|
|
1668
|
-
entry.endpoints.add(endpoint);
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
1673
|
-
for (const [, cem] of queryMap) {
|
|
1674
|
-
if (cem.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
1675
|
-
if (cem.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
1676
|
-
const p = Math.round(cem.endpoints.size / allEndpoints.size * 100);
|
|
1677
|
-
if (p < CROSS_ENDPOINT_PCT) continue;
|
|
1678
|
-
const info = getQueryInfo(cem.first);
|
|
1679
|
-
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1680
|
-
insights.push({
|
|
1681
|
-
severity: "warning",
|
|
1682
|
-
type: "cross-endpoint",
|
|
1683
|
-
title: "Repeated Query Across Endpoints",
|
|
1684
|
-
desc: `${label} runs on ${cem.endpoints.size} of ${allEndpoints.size} endpoints (${p}%).`,
|
|
1685
|
-
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
1686
|
-
nav: "queries"
|
|
1687
|
-
});
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
return insights;
|
|
1691
|
-
}
|
|
1692
|
-
};
|
|
1693
|
-
|
|
1694
|
-
// src/analysis/insights/rules/redundant-query.ts
|
|
1695
1662
|
var redundantQueryRule = {
|
|
1696
1663
|
id: "redundant-query",
|
|
1697
1664
|
check(ctx) {
|
|
@@ -1702,27 +1669,27 @@ var redundantQueryRule = {
|
|
|
1702
1669
|
if (!req) continue;
|
|
1703
1670
|
const endpoint = getEndpointKey(req.method, req.path);
|
|
1704
1671
|
const exact = /* @__PURE__ */ new Map();
|
|
1705
|
-
for (const
|
|
1706
|
-
if (!
|
|
1707
|
-
let entry = exact.get(
|
|
1672
|
+
for (const query of reqQueries) {
|
|
1673
|
+
if (!query.sql) continue;
|
|
1674
|
+
let entry = exact.get(query.sql);
|
|
1708
1675
|
if (!entry) {
|
|
1709
|
-
entry = { count: 0, first:
|
|
1710
|
-
exact.set(
|
|
1676
|
+
entry = { count: 0, first: query };
|
|
1677
|
+
exact.set(query.sql, entry);
|
|
1711
1678
|
}
|
|
1712
1679
|
entry.count++;
|
|
1713
1680
|
}
|
|
1714
|
-
for (const [,
|
|
1715
|
-
if (
|
|
1716
|
-
const info = getQueryInfo(
|
|
1681
|
+
for (const [, entry] of exact) {
|
|
1682
|
+
if (entry.count < REDUNDANT_QUERY_MIN_COUNT) continue;
|
|
1683
|
+
const info = getQueryInfo(entry.first);
|
|
1717
1684
|
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
1718
|
-
const
|
|
1719
|
-
if (seen.has(
|
|
1720
|
-
seen.add(
|
|
1685
|
+
const deduplicationKey = `${endpoint}:${label}`;
|
|
1686
|
+
if (seen.has(deduplicationKey)) continue;
|
|
1687
|
+
seen.add(deduplicationKey);
|
|
1721
1688
|
insights.push({
|
|
1722
1689
|
severity: "warning",
|
|
1723
1690
|
type: "redundant-query",
|
|
1724
1691
|
title: "Redundant Query",
|
|
1725
|
-
desc: `${label} runs ${
|
|
1692
|
+
desc: `${label} runs ${entry.count}x with identical params in ${endpoint}.`,
|
|
1726
1693
|
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.",
|
|
1727
1694
|
nav: "queries"
|
|
1728
1695
|
});
|
|
@@ -1731,179 +1698,16 @@ var redundantQueryRule = {
|
|
|
1731
1698
|
return insights;
|
|
1732
1699
|
}
|
|
1733
1700
|
};
|
|
1734
|
-
|
|
1735
|
-
// src/analysis/insights/rules/error.ts
|
|
1736
|
-
var errorRule = {
|
|
1737
|
-
id: "error",
|
|
1738
|
-
check(ctx) {
|
|
1739
|
-
if (ctx.errors.length === 0) return [];
|
|
1740
|
-
const insights = [];
|
|
1741
|
-
const groups = /* @__PURE__ */ new Map();
|
|
1742
|
-
for (const e of ctx.errors) {
|
|
1743
|
-
const name = e.name || "Error";
|
|
1744
|
-
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1745
|
-
}
|
|
1746
|
-
for (const [name, cnt] of groups) {
|
|
1747
|
-
insights.push({
|
|
1748
|
-
severity: "critical",
|
|
1749
|
-
type: "error",
|
|
1750
|
-
title: "Unhandled Error",
|
|
1751
|
-
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1752
|
-
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1753
|
-
nav: "errors"
|
|
1754
|
-
});
|
|
1755
|
-
}
|
|
1756
|
-
return insights;
|
|
1757
|
-
}
|
|
1758
|
-
};
|
|
1759
|
-
|
|
1760
|
-
// src/analysis/insights/rules/error-hotspot.ts
|
|
1761
|
-
var errorHotspotRule = {
|
|
1762
|
-
id: "error-hotspot",
|
|
1763
|
-
check(ctx) {
|
|
1764
|
-
const insights = [];
|
|
1765
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1766
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1767
|
-
const errorRate = Math.round(g.errors / g.total * 100);
|
|
1768
|
-
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1769
|
-
insights.push({
|
|
1770
|
-
severity: "critical",
|
|
1771
|
-
type: "error-hotspot",
|
|
1772
|
-
title: "Error Hotspot",
|
|
1773
|
-
desc: `${ep} \u2014 ${errorRate}% error rate (${g.errors}/${g.total} requests)`,
|
|
1774
|
-
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1775
|
-
nav: "requests"
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
return insights;
|
|
1780
|
-
}
|
|
1781
|
-
};
|
|
1782
|
-
|
|
1783
|
-
// src/analysis/insights/rules/duplicate.ts
|
|
1784
|
-
var duplicateRule = {
|
|
1785
|
-
id: "duplicate",
|
|
1786
|
-
check(ctx) {
|
|
1787
|
-
const dupCounts = /* @__PURE__ */ new Map();
|
|
1788
|
-
const flowCount = /* @__PURE__ */ new Map();
|
|
1789
|
-
for (const flow of ctx.flows) {
|
|
1790
|
-
if (!flow.requests) continue;
|
|
1791
|
-
const seenInFlow = /* @__PURE__ */ new Set();
|
|
1792
|
-
for (const fr of flow.requests) {
|
|
1793
|
-
if (!fr.isDuplicate) continue;
|
|
1794
|
-
const dupKey = `${fr.method} ${fr.label ?? fr.path ?? fr.url}`;
|
|
1795
|
-
dupCounts.set(dupKey, (dupCounts.get(dupKey) ?? 0) + 1);
|
|
1796
|
-
if (!seenInFlow.has(dupKey)) {
|
|
1797
|
-
seenInFlow.add(dupKey);
|
|
1798
|
-
flowCount.set(dupKey, (flowCount.get(dupKey) ?? 0) + 1);
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
1803
|
-
const insights = [];
|
|
1804
|
-
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
1805
|
-
const d = dupEntries[i];
|
|
1806
|
-
insights.push({
|
|
1807
|
-
severity: "warning",
|
|
1808
|
-
type: "duplicate",
|
|
1809
|
-
title: "Duplicate API Call",
|
|
1810
|
-
desc: `${d.key} loaded ${d.count}x as duplicate across ${d.flows} action${d.flows !== 1 ? "s" : ""}`,
|
|
1811
|
-
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.",
|
|
1812
|
-
nav: "actions"
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
return insights;
|
|
1816
|
-
}
|
|
1817
|
-
};
|
|
1818
|
-
|
|
1819
|
-
// src/utils/format.ts
|
|
1820
|
-
function formatDuration(ms) {
|
|
1821
|
-
if (ms < 1e3) return `${ms}ms`;
|
|
1822
|
-
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1823
|
-
}
|
|
1824
|
-
function formatSize(bytes) {
|
|
1825
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
1826
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1827
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1828
|
-
}
|
|
1829
|
-
function pct(part, total) {
|
|
1830
|
-
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
// src/analysis/insights/rules/slow.ts
|
|
1834
|
-
var slowRule = {
|
|
1835
|
-
id: "slow",
|
|
1836
|
-
check(ctx) {
|
|
1837
|
-
const insights = [];
|
|
1838
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1839
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1840
|
-
const avgMs = Math.round(g.totalDuration / g.total);
|
|
1841
|
-
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1842
|
-
const avgQueryMs = Math.round(g.totalQueryTimeMs / g.total);
|
|
1843
|
-
const avgFetchMs = Math.round(g.totalFetchTimeMs / g.total);
|
|
1844
|
-
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1845
|
-
const parts = [];
|
|
1846
|
-
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1847
|
-
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1848
|
-
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1849
|
-
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1850
|
-
let detail;
|
|
1851
|
-
let slowestMs = 0;
|
|
1852
|
-
for (const [, sd] of g.queryShapeDurations) {
|
|
1853
|
-
const avgShapeMs = sd.totalMs / sd.count;
|
|
1854
|
-
if (avgShapeMs > slowestMs) {
|
|
1855
|
-
slowestMs = avgShapeMs;
|
|
1856
|
-
detail = `Slowest query: ${sd.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${sd.count}x)`;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
insights.push({
|
|
1860
|
-
severity: "warning",
|
|
1861
|
-
type: "slow",
|
|
1862
|
-
title: "Slow Endpoint",
|
|
1863
|
-
desc: `${ep} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1864
|
-
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.",
|
|
1865
|
-
detail,
|
|
1866
|
-
nav: "requests"
|
|
1867
|
-
});
|
|
1868
|
-
}
|
|
1869
|
-
return insights;
|
|
1870
|
-
}
|
|
1871
|
-
};
|
|
1872
|
-
|
|
1873
|
-
// src/analysis/insights/rules/query-heavy.ts
|
|
1874
|
-
var queryHeavyRule = {
|
|
1875
|
-
id: "query-heavy",
|
|
1876
|
-
check(ctx) {
|
|
1877
|
-
const insights = [];
|
|
1878
|
-
for (const [ep, g] of ctx.endpointGroups) {
|
|
1879
|
-
if (g.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1880
|
-
const avgQueries = Math.round(g.queryCount / g.total);
|
|
1881
|
-
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1882
|
-
insights.push({
|
|
1883
|
-
severity: "warning",
|
|
1884
|
-
type: "query-heavy",
|
|
1885
|
-
title: "Query-Heavy Endpoint",
|
|
1886
|
-
desc: `${ep} \u2014 avg ${avgQueries} queries/request`,
|
|
1887
|
-
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1888
|
-
nav: "queries"
|
|
1889
|
-
});
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
return insights;
|
|
1893
|
-
}
|
|
1894
|
-
};
|
|
1895
|
-
|
|
1896
|
-
// src/analysis/insights/rules/select-star.ts
|
|
1897
1701
|
var selectStarRule = {
|
|
1898
1702
|
id: "select-star",
|
|
1899
1703
|
check(ctx) {
|
|
1900
1704
|
const seen = /* @__PURE__ */ new Map();
|
|
1901
1705
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1902
|
-
for (const
|
|
1903
|
-
if (!
|
|
1904
|
-
const isSelectStar = SELECT_STAR_RE.test(
|
|
1706
|
+
for (const query of reqQueries) {
|
|
1707
|
+
if (!query.sql) continue;
|
|
1708
|
+
const isSelectStar = SELECT_STAR_RE.test(query.sql.trim()) || SELECT_DOT_STAR_RE.test(query.sql);
|
|
1905
1709
|
if (!isSelectStar) continue;
|
|
1906
|
-
const info = getQueryInfo(
|
|
1710
|
+
const info = getQueryInfo(query);
|
|
1907
1711
|
const key = info.table || "unknown";
|
|
1908
1712
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
1909
1713
|
}
|
|
@@ -1923,16 +1727,14 @@ var selectStarRule = {
|
|
|
1923
1727
|
return insights;
|
|
1924
1728
|
}
|
|
1925
1729
|
};
|
|
1926
|
-
|
|
1927
|
-
// src/analysis/insights/rules/high-rows.ts
|
|
1928
1730
|
var highRowsRule = {
|
|
1929
1731
|
id: "high-rows",
|
|
1930
1732
|
check(ctx) {
|
|
1931
1733
|
const seen = /* @__PURE__ */ new Map();
|
|
1932
1734
|
for (const [, reqQueries] of ctx.queriesByReq) {
|
|
1933
|
-
for (const
|
|
1934
|
-
if (!
|
|
1935
|
-
const info = getQueryInfo(
|
|
1735
|
+
for (const query of reqQueries) {
|
|
1736
|
+
if (!query.rowCount || query.rowCount <= HIGH_ROW_COUNT) continue;
|
|
1737
|
+
const info = getQueryInfo(query);
|
|
1936
1738
|
const key = `${info.op} ${info.table || "unknown"}`;
|
|
1937
1739
|
let entry = seen.get(key);
|
|
1938
1740
|
if (!entry) {
|
|
@@ -1940,7 +1742,7 @@ var highRowsRule = {
|
|
|
1940
1742
|
seen.set(key, entry);
|
|
1941
1743
|
}
|
|
1942
1744
|
entry.count++;
|
|
1943
|
-
if (
|
|
1745
|
+
if (query.rowCount > entry.max) entry.max = query.rowCount;
|
|
1944
1746
|
}
|
|
1945
1747
|
}
|
|
1946
1748
|
const insights = [];
|
|
@@ -1958,21 +1760,57 @@ var highRowsRule = {
|
|
|
1958
1760
|
return insights;
|
|
1959
1761
|
}
|
|
1960
1762
|
};
|
|
1763
|
+
var queryHeavyRule = {
|
|
1764
|
+
id: "query-heavy",
|
|
1765
|
+
check(ctx) {
|
|
1766
|
+
const insights = [];
|
|
1767
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1768
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1769
|
+
const avgQueries = Math.round(group.queryCount / group.total);
|
|
1770
|
+
if (avgQueries > HIGH_QUERY_COUNT_PER_REQ) {
|
|
1771
|
+
insights.push({
|
|
1772
|
+
severity: "warning",
|
|
1773
|
+
type: "query-heavy",
|
|
1774
|
+
title: "Query-Heavy Endpoint",
|
|
1775
|
+
desc: `${endpointKey} \u2014 avg ${avgQueries} queries/request`,
|
|
1776
|
+
hint: "Too many queries per request increases latency. Combine queries with JOINs, use batch operations, or reduce the number of data fetches.",
|
|
1777
|
+
nav: "queries"
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return insights;
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1961
1784
|
|
|
1962
|
-
// src/
|
|
1785
|
+
// src/utils/format.ts
|
|
1786
|
+
function formatDuration(ms) {
|
|
1787
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
1788
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
1789
|
+
}
|
|
1790
|
+
function formatSize(bytes) {
|
|
1791
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1792
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1793
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1794
|
+
}
|
|
1795
|
+
function pct(part, total) {
|
|
1796
|
+
return total > 0 ? Math.round(part / total * 100) : 0;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// src/analysis/insights/rules/response-rules.ts
|
|
1963
1800
|
var responseOverfetchRule = {
|
|
1964
1801
|
id: "response-overfetch",
|
|
1965
1802
|
check(ctx) {
|
|
1966
1803
|
const insights = [];
|
|
1967
1804
|
const seen = /* @__PURE__ */ new Set();
|
|
1968
|
-
for (const
|
|
1969
|
-
if (isErrorStatus(
|
|
1970
|
-
const
|
|
1971
|
-
if (seen.has(
|
|
1805
|
+
for (const request of ctx.nonStatic) {
|
|
1806
|
+
if (isErrorStatus(request.statusCode) || !request.responseBody) continue;
|
|
1807
|
+
const endpointKey = getEndpointKey(request.method, request.path);
|
|
1808
|
+
if (seen.has(endpointKey)) continue;
|
|
1972
1809
|
let parsed;
|
|
1973
1810
|
try {
|
|
1974
|
-
parsed = JSON.parse(
|
|
1975
|
-
} catch {
|
|
1811
|
+
parsed = JSON.parse(request.responseBody);
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
brakitDebug(`json parse: ${getErrorMessage(e)}`);
|
|
1976
1814
|
continue;
|
|
1977
1815
|
}
|
|
1978
1816
|
const target = unwrapResponse(parsed);
|
|
@@ -1995,12 +1833,12 @@ var responseOverfetchRule = {
|
|
|
1995
1833
|
reasons.push(`${fields.length} fields returned`);
|
|
1996
1834
|
}
|
|
1997
1835
|
if (reasons.length > 0) {
|
|
1998
|
-
seen.add(
|
|
1836
|
+
seen.add(endpointKey);
|
|
1999
1837
|
insights.push({
|
|
2000
1838
|
severity: "info",
|
|
2001
1839
|
type: "response-overfetch",
|
|
2002
1840
|
title: "Response Overfetch",
|
|
2003
|
-
desc: `${
|
|
1841
|
+
desc: `${endpointKey} \u2014 ${reasons.join(", ")}`,
|
|
2004
1842
|
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.",
|
|
2005
1843
|
nav: "requests"
|
|
2006
1844
|
});
|
|
@@ -2009,21 +1847,19 @@ var responseOverfetchRule = {
|
|
|
2009
1847
|
return insights;
|
|
2010
1848
|
}
|
|
2011
1849
|
};
|
|
2012
|
-
|
|
2013
|
-
// src/analysis/insights/rules/large-response.ts
|
|
2014
1850
|
var largeResponseRule = {
|
|
2015
1851
|
id: "large-response",
|
|
2016
1852
|
check(ctx) {
|
|
2017
1853
|
const insights = [];
|
|
2018
|
-
for (const [
|
|
2019
|
-
if (
|
|
2020
|
-
const avgSize = Math.round(
|
|
1854
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1855
|
+
if (group.total < OVERFETCH_MIN_REQUESTS) continue;
|
|
1856
|
+
const avgSize = Math.round(group.totalSize / group.total);
|
|
2021
1857
|
if (avgSize > LARGE_RESPONSE_BYTES) {
|
|
2022
1858
|
insights.push({
|
|
2023
1859
|
severity: "info",
|
|
2024
1860
|
type: "large-response",
|
|
2025
1861
|
title: "Large Response",
|
|
2026
|
-
desc: `${
|
|
1862
|
+
desc: `${endpointKey} \u2014 avg ${formatSize(avgSize)} response`,
|
|
2027
1863
|
hint: "Large API responses increase network transfer time. Implement pagination, field filtering, or response compression.",
|
|
2028
1864
|
nav: "requests"
|
|
2029
1865
|
});
|
|
@@ -2033,7 +1869,51 @@ var largeResponseRule = {
|
|
|
2033
1869
|
}
|
|
2034
1870
|
};
|
|
2035
1871
|
|
|
2036
|
-
// src/analysis/insights/rules/
|
|
1872
|
+
// src/analysis/insights/rules/reliability-rules.ts
|
|
1873
|
+
var errorRule = {
|
|
1874
|
+
id: "error",
|
|
1875
|
+
check(ctx) {
|
|
1876
|
+
if (ctx.errors.length === 0) return [];
|
|
1877
|
+
const insights = [];
|
|
1878
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1879
|
+
for (const error of ctx.errors) {
|
|
1880
|
+
const name = error.name || "Error";
|
|
1881
|
+
groups.set(name, (groups.get(name) ?? 0) + 1);
|
|
1882
|
+
}
|
|
1883
|
+
for (const [name, cnt] of groups) {
|
|
1884
|
+
insights.push({
|
|
1885
|
+
severity: "critical",
|
|
1886
|
+
type: "error",
|
|
1887
|
+
title: "Unhandled Error",
|
|
1888
|
+
desc: `${name} \u2014 occurred ${cnt} time${cnt !== 1 ? "s" : ""}`,
|
|
1889
|
+
hint: "Unhandled errors crash request handlers. Wrap async code in try/catch or add error-handling middleware.",
|
|
1890
|
+
nav: "errors"
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
return insights;
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
var errorHotspotRule = {
|
|
1897
|
+
id: "error-hotspot",
|
|
1898
|
+
check(ctx) {
|
|
1899
|
+
const insights = [];
|
|
1900
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1901
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1902
|
+
const errorRate = Math.round(group.errors / group.total * 100);
|
|
1903
|
+
if (errorRate >= ERROR_RATE_THRESHOLD_PCT) {
|
|
1904
|
+
insights.push({
|
|
1905
|
+
severity: "critical",
|
|
1906
|
+
type: "error-hotspot",
|
|
1907
|
+
title: "Error Hotspot",
|
|
1908
|
+
desc: `${endpointKey} \u2014 ${errorRate}% error rate (${group.errors}/${group.total} requests)`,
|
|
1909
|
+
hint: "This endpoint frequently returns errors. Check the response bodies for error details and stack traces.",
|
|
1910
|
+
nav: "requests"
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return insights;
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
2037
1917
|
var regressionRule = {
|
|
2038
1918
|
id: "regression",
|
|
2039
1919
|
check(ctx) {
|
|
@@ -2070,6 +1950,127 @@ var regressionRule = {
|
|
|
2070
1950
|
return insights;
|
|
2071
1951
|
}
|
|
2072
1952
|
};
|
|
1953
|
+
var slowRule = {
|
|
1954
|
+
id: "slow",
|
|
1955
|
+
check(ctx) {
|
|
1956
|
+
const insights = [];
|
|
1957
|
+
for (const [endpointKey, group] of ctx.endpointGroups) {
|
|
1958
|
+
if (group.total < MIN_REQUESTS_FOR_INSIGHT) continue;
|
|
1959
|
+
const avgMs = Math.round(group.totalDuration / group.total);
|
|
1960
|
+
if (avgMs < SLOW_ENDPOINT_THRESHOLD_MS) continue;
|
|
1961
|
+
const avgQueryMs = Math.round(group.totalQueryTimeMs / group.total);
|
|
1962
|
+
const avgFetchMs = Math.round(group.totalFetchTimeMs / group.total);
|
|
1963
|
+
const avgAppMs = Math.max(0, avgMs - avgQueryMs - avgFetchMs);
|
|
1964
|
+
const parts = [];
|
|
1965
|
+
if (avgQueryMs > 0) parts.push(`DB ${formatDuration(avgQueryMs)} ${pct(avgQueryMs, avgMs)}%`);
|
|
1966
|
+
if (avgFetchMs > 0) parts.push(`Fetch ${formatDuration(avgFetchMs)} ${pct(avgFetchMs, avgMs)}%`);
|
|
1967
|
+
if (avgAppMs > 0) parts.push(`App ${formatDuration(avgAppMs)} ${pct(avgAppMs, avgMs)}%`);
|
|
1968
|
+
const breakdown = parts.length > 0 ? ` [${parts.join(" \xB7 ")}]` : "";
|
|
1969
|
+
let detail;
|
|
1970
|
+
let slowestMs = 0;
|
|
1971
|
+
for (const [, shapeDuration] of group.queryShapeDurations) {
|
|
1972
|
+
const avgShapeMs = shapeDuration.totalMs / shapeDuration.count;
|
|
1973
|
+
if (avgShapeMs > slowestMs) {
|
|
1974
|
+
slowestMs = avgShapeMs;
|
|
1975
|
+
detail = `Slowest query: ${shapeDuration.label} \u2014 avg ${formatDuration(Math.round(avgShapeMs))} (${shapeDuration.count}x)`;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
insights.push({
|
|
1979
|
+
severity: "warning",
|
|
1980
|
+
type: "slow",
|
|
1981
|
+
title: "Slow Endpoint",
|
|
1982
|
+
desc: `${endpointKey} \u2014 avg ${formatDuration(avgMs)}${breakdown}`,
|
|
1983
|
+
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.",
|
|
1984
|
+
detail,
|
|
1985
|
+
nav: "requests"
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
return insights;
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
// src/analysis/insights/rules/pattern-rules.ts
|
|
1993
|
+
var duplicateRule = {
|
|
1994
|
+
id: "duplicate",
|
|
1995
|
+
check(ctx) {
|
|
1996
|
+
const dupCounts = /* @__PURE__ */ new Map();
|
|
1997
|
+
const flowCount = /* @__PURE__ */ new Map();
|
|
1998
|
+
for (const flow of ctx.flows) {
|
|
1999
|
+
if (!flow.requests) continue;
|
|
2000
|
+
const seenInFlow = /* @__PURE__ */ new Set();
|
|
2001
|
+
for (const request of flow.requests) {
|
|
2002
|
+
if (!request.isDuplicate) continue;
|
|
2003
|
+
const deduplicationKey = `${request.method} ${request.label ?? request.path ?? request.url}`;
|
|
2004
|
+
dupCounts.set(deduplicationKey, (dupCounts.get(deduplicationKey) ?? 0) + 1);
|
|
2005
|
+
if (!seenInFlow.has(deduplicationKey)) {
|
|
2006
|
+
seenInFlow.add(deduplicationKey);
|
|
2007
|
+
flowCount.set(deduplicationKey, (flowCount.get(deduplicationKey) ?? 0) + 1);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
const dupEntries = [...dupCounts.entries()].map(([key, count]) => ({ key, count, flows: flowCount.get(key) ?? 0 })).sort((a, b) => b.count - a.count);
|
|
2012
|
+
const insights = [];
|
|
2013
|
+
for (let i = 0; i < Math.min(dupEntries.length, MAX_DUPLICATE_INSIGHTS); i++) {
|
|
2014
|
+
const duplicate = dupEntries[i];
|
|
2015
|
+
insights.push({
|
|
2016
|
+
severity: "warning",
|
|
2017
|
+
type: "duplicate",
|
|
2018
|
+
title: "Duplicate API Call",
|
|
2019
|
+
desc: `${duplicate.key} loaded ${duplicate.count}x as duplicate across ${duplicate.flows} action${duplicate.flows !== 1 ? "s" : ""}`,
|
|
2020
|
+
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.",
|
|
2021
|
+
nav: "actions"
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
return insights;
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
var crossEndpointRule = {
|
|
2028
|
+
id: "cross-endpoint",
|
|
2029
|
+
check(ctx) {
|
|
2030
|
+
const insights = [];
|
|
2031
|
+
const queryMap = /* @__PURE__ */ new Map();
|
|
2032
|
+
const allEndpoints = /* @__PURE__ */ new Set();
|
|
2033
|
+
for (const [reqId, reqQueries] of ctx.queriesByReq) {
|
|
2034
|
+
const req = ctx.reqById.get(reqId);
|
|
2035
|
+
if (!req) continue;
|
|
2036
|
+
const endpoint = getEndpointKey(req.method, req.path);
|
|
2037
|
+
allEndpoints.add(endpoint);
|
|
2038
|
+
const seenInReq = /* @__PURE__ */ new Set();
|
|
2039
|
+
for (const query of reqQueries) {
|
|
2040
|
+
const shape = getQueryShape(query);
|
|
2041
|
+
let entry = queryMap.get(shape);
|
|
2042
|
+
if (!entry) {
|
|
2043
|
+
entry = { endpoints: /* @__PURE__ */ new Set(), count: 0, first: query };
|
|
2044
|
+
queryMap.set(shape, entry);
|
|
2045
|
+
}
|
|
2046
|
+
entry.count++;
|
|
2047
|
+
if (!seenInReq.has(shape)) {
|
|
2048
|
+
seenInReq.add(shape);
|
|
2049
|
+
entry.endpoints.add(endpoint);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (allEndpoints.size >= CROSS_ENDPOINT_MIN_ENDPOINTS) {
|
|
2054
|
+
for (const [, queryMetric] of queryMap) {
|
|
2055
|
+
if (queryMetric.count < CROSS_ENDPOINT_MIN_OCCURRENCES) continue;
|
|
2056
|
+
if (queryMetric.endpoints.size < CROSS_ENDPOINT_MIN_ENDPOINTS) continue;
|
|
2057
|
+
const coveragePct = Math.round(queryMetric.endpoints.size / allEndpoints.size * 100);
|
|
2058
|
+
if (coveragePct < CROSS_ENDPOINT_PCT) continue;
|
|
2059
|
+
const info = getQueryInfo(queryMetric.first);
|
|
2060
|
+
const label = info.op + (info.table ? ` ${info.table}` : "");
|
|
2061
|
+
insights.push({
|
|
2062
|
+
severity: "warning",
|
|
2063
|
+
type: "cross-endpoint",
|
|
2064
|
+
title: "Repeated Query Across Endpoints",
|
|
2065
|
+
desc: `${label} runs on ${queryMetric.endpoints.size} of ${allEndpoints.size} endpoints (${coveragePct}%).`,
|
|
2066
|
+
hint: "This query runs on most of your endpoints. Load it once in middleware or cache the result to avoid redundant database calls.",
|
|
2067
|
+
nav: "queries"
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return insights;
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2073
2074
|
|
|
2074
2075
|
// src/analysis/insights/rules/security.ts
|
|
2075
2076
|
var securityRule = {
|
|
@@ -2144,8 +2145,8 @@ function securityFindingToIssue(finding) {
|
|
|
2144
2145
|
|
|
2145
2146
|
// src/analysis/engine.ts
|
|
2146
2147
|
var AnalysisEngine = class {
|
|
2147
|
-
constructor(
|
|
2148
|
-
this.
|
|
2148
|
+
constructor(services, debounceMs = ANALYSIS_DEBOUNCE_MS) {
|
|
2149
|
+
this.services = services;
|
|
2149
2150
|
this.debounceMs = debounceMs;
|
|
2150
2151
|
this.cachedInsights = [];
|
|
2151
2152
|
this.cachedFindings = [];
|
|
@@ -2154,7 +2155,7 @@ var AnalysisEngine = class {
|
|
|
2154
2155
|
this.scanner = createDefaultScanner();
|
|
2155
2156
|
}
|
|
2156
2157
|
start() {
|
|
2157
|
-
const bus = this.
|
|
2158
|
+
const bus = this.services.bus;
|
|
2158
2159
|
this.subs.add(bus.on("request:completed", () => this.scheduleRecompute()));
|
|
2159
2160
|
this.subs.add(bus.on("telemetry:query", () => this.scheduleRecompute()));
|
|
2160
2161
|
this.subs.add(bus.on("telemetry:error", () => this.scheduleRecompute()));
|
|
@@ -2181,12 +2182,12 @@ var AnalysisEngine = class {
|
|
|
2181
2182
|
}, this.debounceMs);
|
|
2182
2183
|
}
|
|
2183
2184
|
recompute() {
|
|
2184
|
-
const allRequests = this.
|
|
2185
|
-
const queries = this.
|
|
2186
|
-
const errors = this.
|
|
2187
|
-
const logs = this.
|
|
2188
|
-
const fetches = this.
|
|
2189
|
-
const requests =
|
|
2185
|
+
const allRequests = this.services.requestStore.getAll();
|
|
2186
|
+
const queries = this.services.queryStore.getAll();
|
|
2187
|
+
const errors = this.services.errorStore.getAll();
|
|
2188
|
+
const logs = this.services.logStore.getAll();
|
|
2189
|
+
const fetches = this.services.fetchStore.getAll();
|
|
2190
|
+
const requests = keepRecentPerEndpoint(allRequests);
|
|
2190
2191
|
const flows = groupRequestsIntoFlows(requests);
|
|
2191
2192
|
this.cachedFindings = this.scanner.scan({ requests, logs });
|
|
2192
2193
|
this.cachedInsights = computeInsights({
|
|
@@ -2195,38 +2196,34 @@ var AnalysisEngine = class {
|
|
|
2195
2196
|
errors,
|
|
2196
2197
|
flows,
|
|
2197
2198
|
fetches,
|
|
2198
|
-
previousMetrics: this.
|
|
2199
|
+
previousMetrics: this.services.metricsStore.getAll(),
|
|
2199
2200
|
securityFindings: this.cachedFindings
|
|
2200
2201
|
});
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
const
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
issues: issueStore.getAll()
|
|
2222
|
-
};
|
|
2223
|
-
this.registry.get("event-bus").emit("analysis:updated", update);
|
|
2224
|
-
}
|
|
2202
|
+
const issueStore = this.services.issueStore;
|
|
2203
|
+
const currentIssueIds = /* @__PURE__ */ new Set();
|
|
2204
|
+
for (const finding of this.cachedFindings) {
|
|
2205
|
+
const issue = securityFindingToIssue(finding);
|
|
2206
|
+
issueStore.upsert(issue, "passive");
|
|
2207
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2208
|
+
}
|
|
2209
|
+
for (const insight of this.cachedInsights) {
|
|
2210
|
+
const issue = insightToIssue(insight);
|
|
2211
|
+
issueStore.upsert(issue, "passive");
|
|
2212
|
+
currentIssueIds.add(computeIssueId(issue));
|
|
2213
|
+
}
|
|
2214
|
+
const activeEndpoints = extractActiveEndpoints(allRequests);
|
|
2215
|
+
issueStore.reconcile(currentIssueIds, activeEndpoints);
|
|
2216
|
+
const update = {
|
|
2217
|
+
insights: this.cachedInsights,
|
|
2218
|
+
findings: this.cachedFindings,
|
|
2219
|
+
issues: issueStore.getAll()
|
|
2220
|
+
};
|
|
2221
|
+
this.services.bus.emit("analysis:updated", update);
|
|
2225
2222
|
}
|
|
2226
2223
|
};
|
|
2227
2224
|
|
|
2228
2225
|
// src/index.ts
|
|
2229
|
-
var VERSION = "0.
|
|
2226
|
+
var VERSION = "9.0.0";
|
|
2230
2227
|
export {
|
|
2231
2228
|
AdapterRegistry,
|
|
2232
2229
|
AnalysisEngine,
|