brakit 0.7.6 → 0.8.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/dist/api.js CHANGED
@@ -1,6 +1,57 @@
1
- // src/detect/project.ts
2
- import { readFile as readFile2 } from "fs/promises";
3
- import { join } from "path";
1
+ // src/store/finding-store.ts
2
+ import {
3
+ readFileSync as readFileSync2,
4
+ writeFileSync as writeFileSync2,
5
+ existsSync as existsSync2,
6
+ mkdirSync as mkdirSync2,
7
+ renameSync
8
+ } from "fs";
9
+ import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
10
+ import { resolve as resolve2 } from "path";
11
+
12
+ // src/constants/routes.ts
13
+ var DASHBOARD_PREFIX = "/__brakit";
14
+
15
+ // src/constants/limits.ts
16
+ var MAX_REQUEST_ENTRIES = 1e3;
17
+ var MAX_TELEMETRY_ENTRIES = 1e3;
18
+ var MAX_INGEST_BYTES = 10 * 1024 * 1024;
19
+
20
+ // src/constants/thresholds.ts
21
+ var FLOW_GAP_MS = 5e3;
22
+ var SLOW_REQUEST_THRESHOLD_MS = 2e3;
23
+ var MIN_POLLING_SEQUENCE = 3;
24
+ var ENDPOINT_TRUNCATE_LENGTH = 12;
25
+ var N1_QUERY_THRESHOLD = 5;
26
+ var ERROR_RATE_THRESHOLD_PCT = 20;
27
+ var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
28
+ var MIN_REQUESTS_FOR_INSIGHT = 2;
29
+ var HIGH_QUERY_COUNT_PER_REQ = 5;
30
+ var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
31
+ var CROSS_ENDPOINT_PCT = 50;
32
+ var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
33
+ var REDUNDANT_QUERY_MIN_COUNT = 2;
34
+ var LARGE_RESPONSE_BYTES = 51200;
35
+ var HIGH_ROW_COUNT = 100;
36
+ var OVERFETCH_MIN_REQUESTS = 2;
37
+ var OVERFETCH_MIN_FIELDS = 8;
38
+ var OVERFETCH_MIN_INTERNAL_IDS = 2;
39
+ var OVERFETCH_NULL_RATIO = 0.3;
40
+ var REGRESSION_PCT_THRESHOLD = 50;
41
+ var REGRESSION_MIN_INCREASE_MS = 200;
42
+ var REGRESSION_MIN_REQUESTS = 5;
43
+ var QUERY_COUNT_REGRESSION_RATIO = 1.5;
44
+ var OVERFETCH_MANY_FIELDS = 12;
45
+ var OVERFETCH_UNWRAP_MIN_SIZE = 3;
46
+ var MAX_DUPLICATE_INSIGHTS = 3;
47
+ var INSIGHT_WINDOW_PER_ENDPOINT = 2;
48
+ var RESOLVE_AFTER_ABSENCES = 3;
49
+ var RESOLVED_INSIGHT_TTL_MS = 18e5;
50
+
51
+ // src/constants/metrics.ts
52
+ var METRICS_DIR = ".brakit";
53
+ var FINDINGS_FILE = ".brakit/findings.json";
54
+ var FINDINGS_FLUSH_INTERVAL_MS = 1e4;
4
55
 
5
56
  // src/utils/fs.ts
6
57
  import { access } from "fs/promises";
@@ -14,8 +65,191 @@ async function fileExists(path) {
14
65
  return false;
15
66
  }
16
67
  }
68
+ function ensureGitignore(dir, entry) {
69
+ try {
70
+ const gitignorePath = resolve(dir, "../.gitignore");
71
+ if (existsSync(gitignorePath)) {
72
+ const content = readFileSync(gitignorePath, "utf-8");
73
+ if (content.split("\n").some((l) => l.trim() === entry)) return;
74
+ writeFileSync(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
75
+ } else {
76
+ writeFileSync(gitignorePath, entry + "\n");
77
+ }
78
+ } catch {
79
+ }
80
+ }
81
+
82
+ // src/store/finding-id.ts
83
+ import { createHash } from "crypto";
84
+ function computeFindingId(finding) {
85
+ const key = `${finding.rule}:${finding.endpoint}:${finding.desc}`;
86
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
87
+ }
88
+
89
+ // src/store/finding-store.ts
90
+ var FindingStore = class {
91
+ constructor(rootDir) {
92
+ this.rootDir = rootDir;
93
+ this.metricsDir = resolve2(rootDir, METRICS_DIR);
94
+ this.findingsPath = resolve2(rootDir, FINDINGS_FILE);
95
+ this.tmpPath = this.findingsPath + ".tmp";
96
+ this.load();
97
+ }
98
+ findings = /* @__PURE__ */ new Map();
99
+ flushTimer = null;
100
+ dirty = false;
101
+ writing = false;
102
+ findingsPath;
103
+ tmpPath;
104
+ metricsDir;
105
+ start() {
106
+ this.flushTimer = setInterval(
107
+ () => this.flush(),
108
+ FINDINGS_FLUSH_INTERVAL_MS
109
+ );
110
+ this.flushTimer.unref();
111
+ }
112
+ stop() {
113
+ if (this.flushTimer) {
114
+ clearInterval(this.flushTimer);
115
+ this.flushTimer = null;
116
+ }
117
+ this.flushSync();
118
+ }
119
+ upsert(finding, source) {
120
+ const id = computeFindingId(finding);
121
+ const existing = this.findings.get(id);
122
+ const now = Date.now();
123
+ if (existing) {
124
+ existing.lastSeenAt = now;
125
+ existing.occurrences++;
126
+ existing.finding = finding;
127
+ if (existing.state === "resolved") {
128
+ existing.state = "open";
129
+ existing.resolvedAt = null;
130
+ }
131
+ this.dirty = true;
132
+ return existing;
133
+ }
134
+ const stateful = {
135
+ findingId: id,
136
+ state: "open",
137
+ source,
138
+ finding,
139
+ firstSeenAt: now,
140
+ lastSeenAt: now,
141
+ resolvedAt: null,
142
+ occurrences: 1
143
+ };
144
+ this.findings.set(id, stateful);
145
+ this.dirty = true;
146
+ return stateful;
147
+ }
148
+ transition(findingId, state) {
149
+ const finding = this.findings.get(findingId);
150
+ if (!finding) return false;
151
+ finding.state = state;
152
+ if (state === "resolved") {
153
+ finding.resolvedAt = Date.now();
154
+ }
155
+ this.dirty = true;
156
+ return true;
157
+ }
158
+ reconcilePassive(currentFindings) {
159
+ const currentIds = new Set(currentFindings.map(computeFindingId));
160
+ for (const [id, stateful] of this.findings) {
161
+ if (stateful.source === "passive" && stateful.state === "open" && !currentIds.has(id)) {
162
+ stateful.state = "resolved";
163
+ stateful.resolvedAt = Date.now();
164
+ this.dirty = true;
165
+ }
166
+ }
167
+ }
168
+ getAll() {
169
+ return [...this.findings.values()];
170
+ }
171
+ getByState(state) {
172
+ return [...this.findings.values()].filter((f) => f.state === state);
173
+ }
174
+ get(findingId) {
175
+ return this.findings.get(findingId);
176
+ }
177
+ clear() {
178
+ this.findings.clear();
179
+ this.dirty = true;
180
+ }
181
+ load() {
182
+ try {
183
+ if (existsSync2(this.findingsPath)) {
184
+ const raw = readFileSync2(this.findingsPath, "utf-8");
185
+ const parsed = JSON.parse(raw);
186
+ if (parsed?.version === 1 && Array.isArray(parsed.findings)) {
187
+ for (const f of parsed.findings) {
188
+ this.findings.set(f.findingId, f);
189
+ }
190
+ }
191
+ }
192
+ } catch {
193
+ }
194
+ }
195
+ flush() {
196
+ if (!this.dirty) return;
197
+ this.writeAsync();
198
+ }
199
+ flushSync() {
200
+ if (!this.dirty) return;
201
+ try {
202
+ this.ensureDir();
203
+ const data = {
204
+ version: 1,
205
+ findings: [...this.findings.values()]
206
+ };
207
+ writeFileSync2(this.tmpPath, JSON.stringify(data));
208
+ renameSync(this.tmpPath, this.findingsPath);
209
+ this.dirty = false;
210
+ } catch (err) {
211
+ process.stderr.write(
212
+ `[brakit] failed to save findings: ${err.message}
213
+ `
214
+ );
215
+ }
216
+ }
217
+ async writeAsync() {
218
+ if (this.writing) return;
219
+ this.writing = true;
220
+ try {
221
+ if (!existsSync2(this.metricsDir)) {
222
+ await mkdir(this.metricsDir, { recursive: true });
223
+ ensureGitignore(this.metricsDir, METRICS_DIR);
224
+ }
225
+ const data = {
226
+ version: 1,
227
+ findings: [...this.findings.values()]
228
+ };
229
+ await writeFile2(this.tmpPath, JSON.stringify(data));
230
+ await rename(this.tmpPath, this.findingsPath);
231
+ this.dirty = false;
232
+ } catch (err) {
233
+ process.stderr.write(
234
+ `[brakit] failed to save findings: ${err.message}
235
+ `
236
+ );
237
+ } finally {
238
+ this.writing = false;
239
+ if (this.dirty) this.writeAsync();
240
+ }
241
+ }
242
+ ensureDir() {
243
+ if (!existsSync2(this.metricsDir)) {
244
+ mkdirSync2(this.metricsDir, { recursive: true });
245
+ ensureGitignore(this.metricsDir, METRICS_DIR);
246
+ }
247
+ }
248
+ };
17
249
 
18
250
  // src/detect/project.ts
251
+ import { readFile as readFile2 } from "fs/promises";
252
+ import { join } from "path";
19
253
  var FRAMEWORKS = [
20
254
  { name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
21
255
  { name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
@@ -409,34 +643,6 @@ var corsCredentialsRule = {
409
643
  }
410
644
  };
411
645
 
412
- // src/constants/thresholds.ts
413
- var FLOW_GAP_MS = 5e3;
414
- var SLOW_REQUEST_THRESHOLD_MS = 2e3;
415
- var MIN_POLLING_SEQUENCE = 3;
416
- var ENDPOINT_TRUNCATE_LENGTH = 12;
417
- var N1_QUERY_THRESHOLD = 5;
418
- var ERROR_RATE_THRESHOLD_PCT = 20;
419
- var SLOW_ENDPOINT_THRESHOLD_MS = 1e3;
420
- var MIN_REQUESTS_FOR_INSIGHT = 2;
421
- var HIGH_QUERY_COUNT_PER_REQ = 5;
422
- var CROSS_ENDPOINT_MIN_ENDPOINTS = 3;
423
- var CROSS_ENDPOINT_PCT = 50;
424
- var CROSS_ENDPOINT_MIN_OCCURRENCES = 5;
425
- var REDUNDANT_QUERY_MIN_COUNT = 2;
426
- var LARGE_RESPONSE_BYTES = 51200;
427
- var HIGH_ROW_COUNT = 100;
428
- var OVERFETCH_MIN_REQUESTS = 2;
429
- var OVERFETCH_MIN_FIELDS = 8;
430
- var OVERFETCH_MIN_INTERNAL_IDS = 2;
431
- var OVERFETCH_NULL_RATIO = 0.3;
432
- var REGRESSION_PCT_THRESHOLD = 50;
433
- var REGRESSION_MIN_INCREASE_MS = 200;
434
- var REGRESSION_MIN_REQUESTS = 5;
435
- var QUERY_COUNT_REGRESSION_RATIO = 1.5;
436
- var OVERFETCH_MANY_FIELDS = 12;
437
- var OVERFETCH_UNWRAP_MIN_SIZE = 3;
438
- var MAX_DUPLICATE_INSIGHTS = 3;
439
-
440
646
  // src/utils/response.ts
441
647
  function unwrapResponse(parsed) {
442
648
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
@@ -623,13 +829,6 @@ function createDefaultScanner() {
623
829
  return scanner;
624
830
  }
625
831
 
626
- // src/constants/routes.ts
627
- var DASHBOARD_PREFIX = "/__brakit";
628
-
629
- // src/constants/limits.ts
630
- var MAX_REQUEST_ENTRIES = 1e3;
631
- var MAX_TELEMETRY_ENTRIES = 1e3;
632
-
633
832
  // src/utils/static-patterns.ts
634
833
  var STATIC_PATTERNS = [
635
834
  /^\/_next\//,
@@ -775,18 +974,22 @@ import { randomUUID as randomUUID2 } from "crypto";
775
974
  function getEndpointKey(method, path) {
776
975
  return `${method} ${path}`;
777
976
  }
977
+ var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
978
+ function extractEndpointFromDesc(desc) {
979
+ return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
980
+ }
778
981
 
779
982
  // src/store/metrics/persistence.ts
780
983
  import {
781
- readFileSync as readFileSync2,
782
- writeFileSync as writeFileSync2,
783
- mkdirSync as mkdirSync2,
784
- existsSync as existsSync2,
984
+ readFileSync as readFileSync3,
985
+ writeFileSync as writeFileSync3,
986
+ mkdirSync as mkdirSync3,
987
+ existsSync as existsSync3,
785
988
  unlinkSync,
786
- renameSync
989
+ renameSync as renameSync2
787
990
  } from "fs";
788
- import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
789
- import { resolve as resolve2 } from "path";
991
+ import { writeFile as writeFile3, mkdir as mkdir2, rename as rename2 } from "fs/promises";
992
+ import { resolve as resolve3 } from "path";
790
993
 
791
994
  // src/analysis/group.ts
792
995
  import { randomUUID as randomUUID3 } from "crypto";
@@ -1215,6 +1418,23 @@ function createEndpointGroup() {
1215
1418
  queryShapeDurations: /* @__PURE__ */ new Map()
1216
1419
  };
1217
1420
  }
1421
+ function windowByEndpoint(requests) {
1422
+ const byEndpoint = /* @__PURE__ */ new Map();
1423
+ for (const r of requests) {
1424
+ const ep = getEndpointKey(r.method, r.path);
1425
+ let list = byEndpoint.get(ep);
1426
+ if (!list) {
1427
+ list = [];
1428
+ byEndpoint.set(ep, list);
1429
+ }
1430
+ list.push(r);
1431
+ }
1432
+ const windowed = [];
1433
+ for (const [, reqs] of byEndpoint) {
1434
+ windowed.push(...reqs.slice(-INSIGHT_WINDOW_PER_ENDPOINT));
1435
+ }
1436
+ return windowed;
1437
+ }
1218
1438
  function prepareContext(ctx) {
1219
1439
  const nonStatic = ctx.requests.filter(
1220
1440
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
@@ -1222,8 +1442,9 @@ function prepareContext(ctx) {
1222
1442
  const queriesByReq = groupBy(ctx.queries, (q) => q.parentRequestId);
1223
1443
  const fetchesByReq = groupBy(ctx.fetches, (f) => f.parentRequestId);
1224
1444
  const reqById = new Map(nonStatic.map((r) => [r.id, r]));
1445
+ const recent = windowByEndpoint(nonStatic);
1225
1446
  const endpointGroups = /* @__PURE__ */ new Map();
1226
- for (const r of nonStatic) {
1447
+ for (const r of recent) {
1227
1448
  const ep = getEndpointKey(r.method, r.path);
1228
1449
  let g = endpointGroups.get(ep);
1229
1450
  if (!g) {
@@ -1795,10 +2016,66 @@ function computeInsights(ctx) {
1795
2016
  return createDefaultInsightRunner().run(ctx);
1796
2017
  }
1797
2018
 
2019
+ // src/analysis/insight-tracker.ts
2020
+ function computeInsightKey(insight) {
2021
+ const identifier = extractEndpointFromDesc(insight.desc) ?? insight.title;
2022
+ return `${insight.type}:${identifier}`;
2023
+ }
2024
+ var InsightTracker = class {
2025
+ tracked = /* @__PURE__ */ new Map();
2026
+ reconcile(current) {
2027
+ const currentKeys = /* @__PURE__ */ new Set();
2028
+ const now = Date.now();
2029
+ for (const insight of current) {
2030
+ const key = computeInsightKey(insight);
2031
+ currentKeys.add(key);
2032
+ const existing = this.tracked.get(key);
2033
+ if (existing) {
2034
+ existing.insight = insight;
2035
+ existing.lastSeenAt = now;
2036
+ existing.consecutiveAbsences = 0;
2037
+ if (existing.state === "resolved") {
2038
+ existing.state = "open";
2039
+ existing.resolvedAt = null;
2040
+ }
2041
+ } else {
2042
+ this.tracked.set(key, {
2043
+ key,
2044
+ state: "open",
2045
+ insight,
2046
+ firstSeenAt: now,
2047
+ lastSeenAt: now,
2048
+ resolvedAt: null,
2049
+ consecutiveAbsences: 0
2050
+ });
2051
+ }
2052
+ }
2053
+ for (const [key, stateful] of this.tracked) {
2054
+ if (stateful.state === "open" && !currentKeys.has(stateful.key)) {
2055
+ stateful.consecutiveAbsences++;
2056
+ if (stateful.consecutiveAbsences >= RESOLVE_AFTER_ABSENCES) {
2057
+ stateful.state = "resolved";
2058
+ stateful.resolvedAt = now;
2059
+ }
2060
+ } else if (stateful.state === "resolved" && stateful.resolvedAt !== null && now - stateful.resolvedAt > RESOLVED_INSIGHT_TTL_MS) {
2061
+ this.tracked.delete(key);
2062
+ }
2063
+ }
2064
+ return [...this.tracked.values()];
2065
+ }
2066
+ getAll() {
2067
+ return [...this.tracked.values()];
2068
+ }
2069
+ clear() {
2070
+ this.tracked.clear();
2071
+ }
2072
+ };
2073
+
1798
2074
  // src/analysis/engine.ts
1799
2075
  var AnalysisEngine = class {
1800
- constructor(metricsStore, debounceMs = 300) {
2076
+ constructor(metricsStore, findingStore, debounceMs = 300) {
1801
2077
  this.metricsStore = metricsStore;
2078
+ this.findingStore = findingStore;
1802
2079
  this.debounceMs = debounceMs;
1803
2080
  this.scanner = createDefaultScanner();
1804
2081
  this.boundRequestListener = () => this.scheduleRecompute();
@@ -1807,8 +2084,10 @@ var AnalysisEngine = class {
1807
2084
  this.boundLogListener = () => this.scheduleRecompute();
1808
2085
  }
1809
2086
  scanner;
2087
+ insightTracker = new InsightTracker();
1810
2088
  cachedInsights = [];
1811
2089
  cachedFindings = [];
2090
+ cachedStatefulInsights = [];
1812
2091
  debounceTimer = null;
1813
2092
  listeners = [];
1814
2093
  boundRequestListener;
@@ -1844,6 +2123,12 @@ var AnalysisEngine = class {
1844
2123
  getFindings() {
1845
2124
  return this.cachedFindings;
1846
2125
  }
2126
+ getStatefulFindings() {
2127
+ return this.findingStore?.getAll() ?? [];
2128
+ }
2129
+ getStatefulInsights() {
2130
+ return this.cachedStatefulInsights;
2131
+ }
1847
2132
  scheduleRecompute() {
1848
2133
  if (this.debounceTimer) return;
1849
2134
  this.debounceTimer = setTimeout(() => {
@@ -1859,6 +2144,12 @@ var AnalysisEngine = class {
1859
2144
  const fetches = defaultFetchStore.getAll();
1860
2145
  const flows = groupRequestsIntoFlows(requests);
1861
2146
  this.cachedFindings = this.scanner.scan({ requests, logs });
2147
+ if (this.findingStore) {
2148
+ for (const finding of this.cachedFindings) {
2149
+ this.findingStore.upsert(finding, "passive");
2150
+ }
2151
+ this.findingStore.reconcilePassive(this.cachedFindings);
2152
+ }
1862
2153
  this.cachedInsights = computeInsights({
1863
2154
  requests,
1864
2155
  queries,
@@ -1868,9 +2159,16 @@ var AnalysisEngine = class {
1868
2159
  previousMetrics: this.metricsStore.getAll(),
1869
2160
  securityFindings: this.cachedFindings
1870
2161
  });
2162
+ this.cachedStatefulInsights = this.insightTracker.reconcile(this.cachedInsights);
2163
+ const update = {
2164
+ insights: this.cachedInsights,
2165
+ findings: this.cachedFindings,
2166
+ statefulFindings: this.getStatefulFindings(),
2167
+ statefulInsights: this.cachedStatefulInsights
2168
+ };
1871
2169
  for (const fn of this.listeners) {
1872
2170
  try {
1873
- fn(this.cachedInsights, this.cachedFindings);
2171
+ fn(update);
1874
2172
  } catch {
1875
2173
  }
1876
2174
  }
@@ -1878,10 +2176,11 @@ var AnalysisEngine = class {
1878
2176
  };
1879
2177
 
1880
2178
  // src/index.ts
1881
- var VERSION = "0.7.6";
2179
+ var VERSION = "0.8.1";
1882
2180
  export {
1883
2181
  AdapterRegistry,
1884
2182
  AnalysisEngine,
2183
+ FindingStore,
1885
2184
  InsightRunner,
1886
2185
  SecurityScanner,
1887
2186
  VERSION,