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