brakit 0.8.4 → 0.8.6

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,12 +1,94 @@
1
- // src/store/finding-store.ts
2
- import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
1
+ // src/store/issue-store.ts
2
+ import { readFile as readFile2 } from "fs/promises";
3
+ import { readFileSync as readFileSync2, existsSync as existsSync3, unlinkSync } from "fs";
3
4
  import { resolve as resolve2 } from "path";
4
5
 
5
- // src/constants/routes.ts
6
- var DASHBOARD_PREFIX = "/__brakit";
6
+ // src/utils/fs.ts
7
+ import { access, readFile, writeFile } from "fs/promises";
8
+ import { existsSync, readFileSync, writeFileSync } from "fs";
9
+ import { createHash } from "crypto";
10
+ import { homedir } from "os";
11
+ import { resolve, join } from "path";
7
12
 
8
13
  // src/constants/limits.ts
9
- var MAX_INGEST_BYTES = 10 * 1024 * 1024;
14
+ var ANALYSIS_DEBOUNCE_MS = 300;
15
+ var ISSUE_ID_HASH_LENGTH = 16;
16
+ var ISSUES_DATA_VERSION = 2;
17
+ var SECRET_SCAN_ARRAY_LIMIT = 5;
18
+ var PII_SCAN_ARRAY_LIMIT = 10;
19
+ var MIN_SECRET_VALUE_LENGTH = 8;
20
+ var FULL_RECORD_MIN_FIELDS = 5;
21
+ var LIST_PII_MIN_ITEMS = 2;
22
+ var MAX_OBJECT_SCAN_DEPTH = 5;
23
+ var ISSUE_PRUNE_TTL_MS = 10 * 60 * 1e3;
24
+
25
+ // src/utils/log.ts
26
+ var PREFIX = "[brakit]";
27
+ function brakitWarn(message) {
28
+ process.stderr.write(`${PREFIX} ${message}
29
+ `);
30
+ }
31
+ function brakitDebug(message) {
32
+ if (process.env.DEBUG_BRAKIT) {
33
+ process.stderr.write(`${PREFIX}:debug ${message}
34
+ `);
35
+ }
36
+ }
37
+
38
+ // src/utils/type-guards.ts
39
+ function getErrorMessage(err) {
40
+ if (err instanceof Error) return err.message;
41
+ if (typeof err === "string") return err;
42
+ return String(err);
43
+ }
44
+ function validateIssuesData(parsed) {
45
+ if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.version === ISSUES_DATA_VERSION && Array.isArray(parsed.issues)) {
46
+ return parsed;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // src/utils/fs.ts
52
+ async function fileExists(path) {
53
+ try {
54
+ await access(path);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ function ensureGitignore(dir, entry) {
61
+ try {
62
+ const gitignorePath = resolve(dir, "../.gitignore");
63
+ if (existsSync(gitignorePath)) {
64
+ const content = readFileSync(gitignorePath, "utf-8");
65
+ if (content.split("\n").some((l) => l.trim() === entry)) return;
66
+ writeFileSync(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
67
+ } else {
68
+ writeFileSync(gitignorePath, entry + "\n");
69
+ }
70
+ } catch (err) {
71
+ brakitDebug(`ensureGitignore failed: ${getErrorMessage(err)}`);
72
+ }
73
+ }
74
+ async function ensureGitignoreAsync(dir, entry) {
75
+ try {
76
+ const gitignorePath = resolve(dir, "../.gitignore");
77
+ if (await fileExists(gitignorePath)) {
78
+ const content = await readFile(gitignorePath, "utf-8");
79
+ if (content.split("\n").some((l) => l.trim() === entry)) return;
80
+ await writeFile(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
81
+ } else {
82
+ await writeFile(gitignorePath, entry + "\n");
83
+ }
84
+ } catch (err) {
85
+ brakitDebug(`ensureGitignoreAsync failed: ${getErrorMessage(err)}`);
86
+ }
87
+ }
88
+
89
+ // src/constants/metrics.ts
90
+ var ISSUES_FILE = "issues.json";
91
+ var ISSUES_FLUSH_INTERVAL_MS = 1e4;
10
92
 
11
93
  // src/constants/thresholds.ts
12
94
  var FLOW_GAP_MS = 5e3;
@@ -35,24 +117,9 @@ var QUERY_COUNT_REGRESSION_RATIO = 1.5;
35
117
  var OVERFETCH_MANY_FIELDS = 12;
36
118
  var OVERFETCH_UNWRAP_MIN_SIZE = 3;
37
119
  var MAX_DUPLICATE_INSIGHTS = 3;
38
- var INSIGHT_WINDOW_PER_ENDPOINT = 2;
39
- var RESOLVE_AFTER_ABSENCES = 3;
40
- var RESOLVED_INSIGHT_TTL_MS = 18e5;
41
-
42
- // src/constants/metrics.ts
43
- var METRICS_DIR = ".brakit";
44
- var FINDINGS_FILE = ".brakit/findings.json";
45
- var FINDINGS_FLUSH_INTERVAL_MS = 1e4;
46
-
47
- // src/constants/severity.ts
48
- var SEVERITY_CRITICAL = "critical";
49
- var SEVERITY_WARNING = "warning";
50
- var SEVERITY_INFO = "info";
51
- var SEVERITY_ICON_MAP = {
52
- [SEVERITY_CRITICAL]: { icon: "\u2717", cls: "critical" },
53
- [SEVERITY_WARNING]: { icon: "\u26A0", cls: "warning" },
54
- [SEVERITY_INFO]: { icon: "\u2139", cls: "info" }
55
- };
120
+ var INSIGHT_WINDOW_PER_ENDPOINT = 20;
121
+ var CLEAN_HITS_FOR_RESOLUTION = 5;
122
+ var STALE_ISSUE_TTL_MS = 30 * 60 * 1e3;
56
123
 
57
124
  // src/utils/atomic-writer.ts
58
125
  import {
@@ -62,41 +129,6 @@ import {
62
129
  renameSync
63
130
  } from "fs";
64
131
  import { writeFile as writeFile2, mkdir, rename } from "fs/promises";
65
-
66
- // src/utils/fs.ts
67
- import { access } from "fs/promises";
68
- import { existsSync, readFileSync, writeFileSync } from "fs";
69
- import { resolve } from "path";
70
- async function fileExists(path) {
71
- try {
72
- await access(path);
73
- return true;
74
- } catch {
75
- return false;
76
- }
77
- }
78
- function ensureGitignore(dir, entry) {
79
- try {
80
- const gitignorePath = resolve(dir, "../.gitignore");
81
- if (existsSync(gitignorePath)) {
82
- const content = readFileSync(gitignorePath, "utf-8");
83
- if (content.split("\n").some((l) => l.trim() === entry)) return;
84
- writeFileSync(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
85
- } else {
86
- writeFileSync(gitignorePath, entry + "\n");
87
- }
88
- } catch {
89
- }
90
- }
91
-
92
- // src/utils/log.ts
93
- var PREFIX = "[brakit]";
94
- function brakitWarn(message) {
95
- process.stderr.write(`${PREFIX} ${message}
96
- `);
97
- }
98
-
99
- // src/utils/atomic-writer.ts
100
132
  var AtomicWriter = class {
101
133
  constructor(opts) {
102
134
  this.opts = opts;
@@ -111,7 +143,7 @@ var AtomicWriter = class {
111
143
  writeFileSync2(this.tmpPath, content);
112
144
  renameSync(this.tmpPath, this.opts.filePath);
113
145
  } catch (err) {
114
- brakitWarn(`failed to save ${this.opts.label}: ${err.message}`);
146
+ brakitWarn(`failed to save ${this.opts.label}: ${getErrorMessage(err)}`);
115
147
  }
116
148
  }
117
149
  async writeAsync(content) {
@@ -125,13 +157,14 @@ var AtomicWriter = class {
125
157
  await writeFile2(this.tmpPath, content);
126
158
  await rename(this.tmpPath, this.opts.filePath);
127
159
  } catch (err) {
128
- brakitWarn(`failed to save ${this.opts.label}: ${err.message}`);
160
+ brakitWarn(`failed to save ${this.opts.label}: ${getErrorMessage(err)}`);
129
161
  } finally {
130
162
  this.writing = false;
131
163
  if (this.pendingContent !== null) {
132
164
  const next = this.pendingContent;
133
165
  this.pendingContent = null;
134
- this.writeAsync(next);
166
+ this.writeAsync(next).catch(() => {
167
+ });
135
168
  }
136
169
  }
137
170
  }
@@ -144,45 +177,44 @@ var AtomicWriter = class {
144
177
  }
145
178
  }
146
179
  async ensureDirAsync() {
147
- if (!existsSync2(this.opts.dir)) {
180
+ if (!await fileExists(this.opts.dir)) {
148
181
  await mkdir(this.opts.dir, { recursive: true });
149
182
  if (this.opts.gitignoreEntry) {
150
- ensureGitignore(this.opts.dir, this.opts.gitignoreEntry);
183
+ await ensureGitignoreAsync(this.opts.dir, this.opts.gitignoreEntry);
151
184
  }
152
185
  }
153
186
  }
154
187
  };
155
188
 
156
- // src/store/finding-id.ts
157
- import { createHash } from "crypto";
158
- function computeFindingId(finding) {
159
- const key = `${finding.rule}:${finding.endpoint}:${finding.desc}`;
160
- return createHash("sha256").update(key).digest("hex").slice(0, 16);
189
+ // src/utils/issue-id.ts
190
+ import { createHash as createHash2 } from "crypto";
191
+ function computeIssueId(issue) {
192
+ const stableDesc = issue.desc.replace(/\d[\d,.]*\s*\w*/g, "#");
193
+ const key = `${issue.rule}:${issue.endpoint ?? "global"}:${stableDesc}`;
194
+ return createHash2("sha256").update(key).digest("hex").slice(0, ISSUE_ID_HASH_LENGTH);
161
195
  }
162
196
 
163
- // src/store/finding-store.ts
164
- var FindingStore = class {
165
- constructor(rootDir) {
166
- this.rootDir = rootDir;
167
- const metricsDir = resolve2(rootDir, METRICS_DIR);
168
- this.findingsPath = resolve2(rootDir, FINDINGS_FILE);
197
+ // src/store/issue-store.ts
198
+ var IssueStore = class {
199
+ constructor(dataDir) {
200
+ this.dataDir = dataDir;
201
+ this.issuesPath = resolve2(dataDir, ISSUES_FILE);
169
202
  this.writer = new AtomicWriter({
170
- dir: metricsDir,
171
- filePath: this.findingsPath,
172
- gitignoreEntry: METRICS_DIR,
173
- label: "findings"
203
+ dir: dataDir,
204
+ filePath: this.issuesPath,
205
+ label: "issues"
174
206
  });
175
- this.load();
176
207
  }
177
- findings = /* @__PURE__ */ new Map();
208
+ issues = /* @__PURE__ */ new Map();
178
209
  flushTimer = null;
179
210
  dirty = false;
180
211
  writer;
181
- findingsPath;
212
+ issuesPath;
182
213
  start() {
214
+ this.loadAsync().catch((err) => brakitDebug(`IssueStore: async load failed: ${err}`));
183
215
  this.flushTimer = setInterval(
184
216
  () => this.flush(),
185
- FINDINGS_FLUSH_INTERVAL_MS
217
+ ISSUES_FLUSH_INTERVAL_MS
186
218
  );
187
219
  this.flushTimer.unref();
188
220
  }
@@ -193,91 +225,150 @@ var FindingStore = class {
193
225
  }
194
226
  this.flushSync();
195
227
  }
196
- upsert(finding, source) {
197
- const id = computeFindingId(finding);
198
- const existing = this.findings.get(id);
228
+ upsert(issue, source) {
229
+ const id = computeIssueId(issue);
230
+ const existing = this.issues.get(id);
199
231
  const now = Date.now();
200
232
  if (existing) {
201
233
  existing.lastSeenAt = now;
202
234
  existing.occurrences++;
203
- existing.finding = finding;
204
- if (existing.state === "resolved") {
205
- existing.state = "open";
235
+ existing.issue = issue;
236
+ existing.cleanHitsSinceLastSeen = 0;
237
+ if (existing.state === "resolved" || existing.state === "stale") {
238
+ existing.state = "regressed";
206
239
  existing.resolvedAt = null;
207
240
  }
208
241
  this.dirty = true;
209
242
  return existing;
210
243
  }
211
244
  const stateful = {
212
- findingId: id,
245
+ issueId: id,
213
246
  state: "open",
214
247
  source,
215
- finding,
248
+ category: issue.category,
249
+ issue,
216
250
  firstSeenAt: now,
217
251
  lastSeenAt: now,
218
252
  resolvedAt: null,
219
- occurrences: 1
253
+ occurrences: 1,
254
+ cleanHitsSinceLastSeen: 0,
255
+ aiStatus: null,
256
+ aiNotes: null
220
257
  };
221
- this.findings.set(id, stateful);
258
+ this.issues.set(id, stateful);
222
259
  this.dirty = true;
223
260
  return stateful;
224
261
  }
225
- transition(findingId, state) {
226
- const finding = this.findings.get(findingId);
227
- if (!finding) return false;
228
- finding.state = state;
229
- if (state === "resolved") {
230
- finding.resolvedAt = Date.now();
231
- }
232
- this.dirty = true;
233
- return true;
234
- }
235
262
  /**
236
- * Reconcile passive findings against the current analysis results.
263
+ * Reconcile issues against the current analysis results using evidence-based resolution.
237
264
  *
238
- * Passive findings are detected by continuous scanning (not user-triggered).
239
- * When a previously-seen finding is absent from the current results, it means
240
- * the issue has been fixed — transition it to "resolved" automatically.
241
- * Active findings (from MCP verify-fix) are not auto-resolved because they
242
- * require explicit verification.
265
+ * @param currentIssueIds - IDs of issues detected in the current analysis cycle
266
+ * @param activeEndpoints - Endpoints that had requests in the current cycle
243
267
  */
244
- reconcilePassive(currentFindings) {
245
- const currentIds = new Set(currentFindings.map(computeFindingId));
246
- for (const [id, stateful] of this.findings) {
247
- if (stateful.source === "passive" && stateful.state === "open" && !currentIds.has(id)) {
248
- stateful.state = "resolved";
249
- stateful.resolvedAt = Date.now();
268
+ reconcile(currentIssueIds, activeEndpoints) {
269
+ const now = Date.now();
270
+ for (const [, stateful] of this.issues) {
271
+ const isActive = stateful.state === "open" || stateful.state === "fixing" || stateful.state === "regressed";
272
+ if (!isActive) continue;
273
+ if (currentIssueIds.has(stateful.issueId)) continue;
274
+ const endpoint = stateful.issue.endpoint;
275
+ if (endpoint && activeEndpoints.has(endpoint)) {
276
+ stateful.cleanHitsSinceLastSeen++;
277
+ if (stateful.cleanHitsSinceLastSeen >= CLEAN_HITS_FOR_RESOLUTION) {
278
+ stateful.state = "resolved";
279
+ stateful.resolvedAt = now;
280
+ }
281
+ this.dirty = true;
282
+ } else if (now - stateful.lastSeenAt > STALE_ISSUE_TTL_MS) {
283
+ stateful.state = "stale";
284
+ this.dirty = true;
285
+ }
286
+ }
287
+ for (const [id, stateful] of this.issues) {
288
+ if (stateful.state === "resolved" && stateful.resolvedAt && now - stateful.resolvedAt > ISSUE_PRUNE_TTL_MS) {
289
+ this.issues.delete(id);
290
+ this.dirty = true;
291
+ } else if (stateful.state === "stale" && now - stateful.lastSeenAt > STALE_ISSUE_TTL_MS + ISSUE_PRUNE_TTL_MS) {
292
+ this.issues.delete(id);
250
293
  this.dirty = true;
251
294
  }
252
295
  }
253
296
  }
297
+ transition(issueId, state) {
298
+ const issue = this.issues.get(issueId);
299
+ if (!issue) return false;
300
+ issue.state = state;
301
+ if (state === "resolved") {
302
+ issue.resolvedAt = Date.now();
303
+ }
304
+ this.dirty = true;
305
+ return true;
306
+ }
307
+ reportFix(issueId, status, notes) {
308
+ const issue = this.issues.get(issueId);
309
+ if (!issue) return false;
310
+ issue.aiStatus = status;
311
+ issue.aiNotes = notes;
312
+ if (status === "fixed") {
313
+ issue.state = "fixing";
314
+ }
315
+ this.dirty = true;
316
+ return true;
317
+ }
254
318
  getAll() {
255
- return [...this.findings.values()];
319
+ return [...this.issues.values()];
256
320
  }
257
321
  getByState(state) {
258
- return [...this.findings.values()].filter((f) => f.state === state);
322
+ return [...this.issues.values()].filter((i) => i.state === state);
259
323
  }
260
- get(findingId) {
261
- return this.findings.get(findingId);
324
+ getByCategory(category) {
325
+ return [...this.issues.values()].filter((i) => i.category === category);
262
326
  }
263
- clear() {
264
- this.findings.clear();
265
- this.dirty = true;
327
+ get(issueId) {
328
+ return this.issues.get(issueId);
266
329
  }
267
- load() {
330
+ clear() {
331
+ this.issues.clear();
332
+ this.dirty = false;
268
333
  try {
269
- if (existsSync3(this.findingsPath)) {
270
- const raw = readFileSync2(this.findingsPath, "utf-8");
271
- const parsed = JSON.parse(raw);
272
- if (parsed?.version === 1 && Array.isArray(parsed.findings)) {
273
- for (const f of parsed.findings) {
274
- this.findings.set(f.findingId, f);
275
- }
276
- }
334
+ if (existsSync3(this.issuesPath)) {
335
+ unlinkSync(this.issuesPath);
277
336
  }
278
337
  } catch {
279
338
  }
280
339
  }
340
+ isDirty() {
341
+ return this.dirty;
342
+ }
343
+ async loadAsync() {
344
+ try {
345
+ if (await fileExists(this.issuesPath)) {
346
+ const raw = await readFile2(this.issuesPath, "utf-8");
347
+ this.hydrate(raw);
348
+ }
349
+ } catch (err) {
350
+ brakitDebug(`IssueStore: could not load issues file, starting fresh: ${err}`);
351
+ }
352
+ }
353
+ /** Sync load for tests only — not used in production paths. */
354
+ loadSync() {
355
+ try {
356
+ if (existsSync3(this.issuesPath)) {
357
+ const raw = readFileSync2(this.issuesPath, "utf-8");
358
+ this.hydrate(raw);
359
+ }
360
+ } catch (err) {
361
+ brakitDebug(`IssueStore: could not load issues file, starting fresh: ${err}`);
362
+ }
363
+ }
364
+ /** Parse and populate issues from a raw JSON string. */
365
+ hydrate(raw) {
366
+ const validated = validateIssuesData(JSON.parse(raw));
367
+ if (!validated) return;
368
+ for (const issue of validated.issues) {
369
+ this.issues.set(issue.issueId, issue);
370
+ }
371
+ }
281
372
  flush() {
282
373
  if (!this.dirty) return;
283
374
  this.writer.writeAsync(this.serialize());
@@ -290,17 +381,17 @@ var FindingStore = class {
290
381
  }
291
382
  serialize() {
292
383
  const data = {
293
- version: 1,
294
- findings: [...this.findings.values()]
384
+ version: ISSUES_DATA_VERSION,
385
+ issues: [...this.issues.values()]
295
386
  };
296
387
  return JSON.stringify(data);
297
388
  }
298
389
  };
299
390
 
300
391
  // src/detect/project.ts
301
- import { readFile as readFile2 } from "fs/promises";
392
+ import { readFile as readFile3, readdir } from "fs/promises";
302
393
  import { existsSync as existsSync4 } from "fs";
303
- import { join } from "path";
394
+ import { join as join2, relative } from "path";
304
395
  var FRAMEWORKS = [
305
396
  { name: "nextjs", dep: "next", devCmd: "next dev", bin: "next", defaultPort: 3e3, devArgs: ["dev", "--port"] },
306
397
  { name: "remix", dep: "@remix-run/dev", devCmd: "remix dev", bin: "remix", defaultPort: 3e3, devArgs: ["dev"] },
@@ -309,24 +400,24 @@ var FRAMEWORKS = [
309
400
  { name: "astro", dep: "astro", devCmd: "astro dev", bin: "astro", defaultPort: 4321, devArgs: ["dev", "--port"] }
310
401
  ];
311
402
  async function detectProject(rootDir) {
312
- const pkgPath = join(rootDir, "package.json");
313
- const raw = await readFile2(pkgPath, "utf-8");
403
+ const pkgPath = join2(rootDir, "package.json");
404
+ const raw = await readFile3(pkgPath, "utf-8");
314
405
  const pkg = JSON.parse(raw);
315
406
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
316
407
  const framework = detectFrameworkFromDeps(allDeps);
317
408
  const matched = FRAMEWORKS.find((f) => f.name === framework);
318
409
  const devCommand = matched?.devCmd ?? "";
319
- const devBin = matched ? join(rootDir, "node_modules", ".bin", matched.bin) : "";
410
+ const devBin = matched ? join2(rootDir, "node_modules", ".bin", matched.bin) : "";
320
411
  const defaultPort = matched?.defaultPort ?? 3e3;
321
412
  const packageManager = await detectPackageManager(rootDir);
322
413
  return { framework, devCommand, devBin, defaultPort, packageManager };
323
414
  }
324
415
  async function detectPackageManager(rootDir) {
325
- if (await fileExists(join(rootDir, "bun.lockb"))) return "bun";
326
- if (await fileExists(join(rootDir, "bun.lock"))) return "bun";
327
- if (await fileExists(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
328
- if (await fileExists(join(rootDir, "yarn.lock"))) return "yarn";
329
- if (await fileExists(join(rootDir, "package-lock.json"))) return "npm";
416
+ if (await fileExists(join2(rootDir, "bun.lockb"))) return "bun";
417
+ if (await fileExists(join2(rootDir, "bun.lock"))) return "bun";
418
+ if (await fileExists(join2(rootDir, "pnpm-lock.yaml"))) return "pnpm";
419
+ if (await fileExists(join2(rootDir, "yarn.lock"))) return "yarn";
420
+ if (await fileExists(join2(rootDir, "package-lock.json"))) return "npm";
330
421
  return "unknown";
331
422
  }
332
423
  function detectFrameworkFromDeps(allDeps) {
@@ -368,15 +459,47 @@ var AdapterRegistry = class {
368
459
  }
369
460
  };
370
461
 
462
+ // src/utils/response.ts
463
+ function tryParseJson(body) {
464
+ if (!body) return null;
465
+ try {
466
+ return JSON.parse(body);
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+ function unwrapResponse(parsed) {
472
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
473
+ const obj = parsed;
474
+ const keys = Object.keys(obj);
475
+ if (keys.length > 3) return parsed;
476
+ let best = null;
477
+ let bestSize = 0;
478
+ for (const key of keys) {
479
+ const val = obj[key];
480
+ if (Array.isArray(val) && val.length > bestSize) {
481
+ best = val;
482
+ bestSize = val.length;
483
+ } else if (val && typeof val === "object" && !Array.isArray(val)) {
484
+ const size = Object.keys(val).length;
485
+ if (size > bestSize) {
486
+ best = val;
487
+ bestSize = size;
488
+ }
489
+ }
490
+ }
491
+ return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
492
+ }
493
+
371
494
  // src/analysis/rules/patterns.ts
372
495
  var SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
373
496
  var TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
374
497
  var SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
375
- var STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections/;
498
+ var STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections|Traceback \(most recent call last\)|File ".+", line \d+/;
376
499
  var DB_CONN_RE = /(postgres|mysql|mongodb|redis):\/\//;
377
500
  var SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i;
378
- var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/;
379
- var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-\.+\/]{8,}/i;
501
+ var SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-.+/]{8,}/;
502
+ var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-.+/]{8,}/i;
380
503
  var MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
381
504
  var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
382
505
  var INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
@@ -388,37 +511,41 @@ var RULE_HINTS = {
388
511
  "token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
389
512
  "stack-trace-leak": "Use a custom error handler that returns generic messages in production.",
390
513
  "error-info-leak": "Sanitize error responses. Return generic messages instead of internal details.",
514
+ "insecure-cookie": "Set HttpOnly and SameSite flags on all cookies.",
391
515
  "sensitive-logs": "Redact PII before logging. Never log passwords or tokens.",
392
516
  "cors-credentials": "Cannot use credentials:true with origin:*. Specify explicit origins.",
393
- "insecure-cookie": "Set HttpOnly and SameSite flags on all cookies.",
394
517
  "response-pii-leak": "API responses should return minimal data. Don't echo back full user records \u2014 select only the fields the client needs."
395
518
  };
396
519
 
397
- // src/analysis/rules/exposed-secret.ts
398
- function tryParseJson(body) {
399
- if (!body) return null;
400
- try {
401
- return JSON.parse(body);
402
- } catch {
403
- return null;
404
- }
520
+ // src/utils/http-status.ts
521
+ function isErrorStatus(code) {
522
+ return code >= 400;
523
+ }
524
+ function isServerError(code) {
525
+ return code >= 500;
526
+ }
527
+ function isRedirect(code) {
528
+ return code >= 300 && code < 400;
405
529
  }
406
- function findSecretKeys(obj, prefix) {
530
+
531
+ // src/analysis/rules/exposed-secret.ts
532
+ function findSecretKeys(obj, prefix, depth = 0) {
407
533
  const found = [];
534
+ if (depth >= MAX_OBJECT_SCAN_DEPTH) return found;
408
535
  if (!obj || typeof obj !== "object") return found;
409
536
  if (Array.isArray(obj)) {
410
- for (let i = 0; i < Math.min(obj.length, 5); i++) {
411
- found.push(...findSecretKeys(obj[i], prefix));
537
+ for (let i = 0; i < Math.min(obj.length, SECRET_SCAN_ARRAY_LIMIT); i++) {
538
+ found.push(...findSecretKeys(obj[i], prefix, depth + 1));
412
539
  }
413
540
  return found;
414
541
  }
415
542
  for (const k of Object.keys(obj)) {
416
543
  const val = obj[k];
417
- if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= 8 && !MASKED_RE.test(val)) {
544
+ if (SECRET_KEYS.test(k) && typeof val === "string" && val.length >= MIN_SECRET_VALUE_LENGTH && !MASKED_RE.test(val)) {
418
545
  found.push(k);
419
546
  }
420
547
  if (typeof val === "object" && val !== null) {
421
- found.push(...findSecretKeys(val, prefix + k + "."));
548
+ found.push(...findSecretKeys(val, prefix + k + ".", depth + 1));
422
549
  }
423
550
  }
424
551
  return found;
@@ -432,8 +559,8 @@ var exposedSecretRule = {
432
559
  const findings = [];
433
560
  const seen = /* @__PURE__ */ new Map();
434
561
  for (const r of ctx.requests) {
435
- if (r.statusCode >= 400) continue;
436
- const parsed = tryParseJson(r.responseBody);
562
+ if (isErrorStatus(r.statusCode)) continue;
563
+ const parsed = ctx.parsedBodies.response.get(r.id);
437
564
  if (!parsed) continue;
438
565
  const keys = findSecretKeys(parsed, "");
439
566
  if (keys.length === 0) continue;
@@ -586,7 +713,7 @@ var errorInfoLeakRule = {
586
713
 
587
714
  // src/analysis/rules/insecure-cookie.ts
588
715
  function isFrameworkResponse(r) {
589
- if (r.statusCode >= 300 && r.statusCode < 400) return true;
716
+ if (isRedirect(r.statusCode)) return true;
590
717
  if (r.path?.startsWith("/__")) return true;
591
718
  if (r.responseHeaders?.["x-middleware-rewrite"]) return true;
592
719
  return false;
@@ -692,48 +819,15 @@ var corsCredentialsRule = {
692
819
  }
693
820
  };
694
821
 
695
- // src/utils/response.ts
696
- function unwrapResponse(parsed) {
697
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
698
- const obj = parsed;
699
- const keys = Object.keys(obj);
700
- if (keys.length > 3) return parsed;
701
- let best = null;
702
- let bestSize = 0;
703
- for (const key of keys) {
704
- const val = obj[key];
705
- if (Array.isArray(val) && val.length > bestSize) {
706
- best = val;
707
- bestSize = val.length;
708
- } else if (val && typeof val === "object" && !Array.isArray(val)) {
709
- const size = Object.keys(val).length;
710
- if (size > bestSize) {
711
- best = val;
712
- bestSize = size;
713
- }
714
- }
715
- }
716
- return best && bestSize >= OVERFETCH_UNWRAP_MIN_SIZE ? best : parsed;
717
- }
718
-
719
822
  // src/analysis/rules/response-pii-leak.ts
720
823
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH"]);
721
- var FULL_RECORD_MIN_FIELDS = 5;
722
- var LIST_PII_MIN_ITEMS = 2;
723
- function tryParseJson2(body) {
724
- if (!body) return null;
725
- try {
726
- return JSON.parse(body);
727
- } catch {
728
- return null;
729
- }
730
- }
731
- function findEmails(obj) {
824
+ function findEmails(obj, depth = 0) {
732
825
  const emails = [];
826
+ if (depth >= MAX_OBJECT_SCAN_DEPTH) return emails;
733
827
  if (!obj || typeof obj !== "object") return emails;
734
828
  if (Array.isArray(obj)) {
735
- for (let i = 0; i < Math.min(obj.length, 10); i++) {
736
- emails.push(...findEmails(obj[i]));
829
+ for (let i = 0; i < Math.min(obj.length, PII_SCAN_ARRAY_LIMIT); i++) {
830
+ emails.push(...findEmails(obj[i], depth + 1));
737
831
  }
738
832
  return emails;
739
833
  }
@@ -741,7 +835,7 @@ function findEmails(obj) {
741
835
  if (typeof v === "string" && EMAIL_RE.test(v)) {
742
836
  emails.push(v);
743
837
  } else if (typeof v === "object" && v !== null) {
744
- emails.push(...findEmails(v));
838
+ emails.push(...findEmails(v, depth + 1));
745
839
  }
746
840
  }
747
841
  return emails;
@@ -760,48 +854,47 @@ function hasInternalIds(obj) {
760
854
  }
761
855
  return false;
762
856
  }
763
- function detectPII(method, reqBody, resBody) {
764
- const target = unwrapResponse(resBody);
765
- if (WRITE_METHODS.has(method) && reqBody && typeof reqBody === "object") {
766
- const reqEmails = findEmails(reqBody);
767
- if (reqEmails.length > 0) {
768
- const resEmails = findEmails(target);
769
- const echoed = reqEmails.filter((e) => resEmails.includes(e));
770
- if (echoed.length > 0) {
771
- const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
772
- if (hasInternalIds(inspectObj) || topLevelFieldCount(inspectObj) >= FULL_RECORD_MIN_FIELDS) {
773
- return { reason: "echo", emailCount: echoed.length };
774
- }
775
- }
776
- }
857
+ function detectEchoPII(method, reqBody, target) {
858
+ if (!WRITE_METHODS.has(method) || !reqBody || typeof reqBody !== "object") return null;
859
+ const reqEmails = findEmails(reqBody);
860
+ if (reqEmails.length === 0) return null;
861
+ const resEmails = findEmails(target);
862
+ const echoed = reqEmails.filter((e) => resEmails.includes(e));
863
+ if (echoed.length === 0) return null;
864
+ const inspectObj = Array.isArray(target) && target.length > 0 ? target[0] : target;
865
+ if (hasInternalIds(inspectObj) || topLevelFieldCount(inspectObj) >= FULL_RECORD_MIN_FIELDS) {
866
+ return { reason: "echo", emailCount: echoed.length };
777
867
  }
778
- if (target && typeof target === "object" && !Array.isArray(target)) {
779
- const fields = topLevelFieldCount(target);
780
- if (fields >= FULL_RECORD_MIN_FIELDS && hasInternalIds(target)) {
781
- const emails = findEmails(target);
782
- if (emails.length > 0) {
783
- return { reason: "full-record", emailCount: emails.length };
784
- }
868
+ return null;
869
+ }
870
+ function detectFullRecordPII(target) {
871
+ if (!target || typeof target !== "object" || Array.isArray(target)) return null;
872
+ const fields = topLevelFieldCount(target);
873
+ if (fields < FULL_RECORD_MIN_FIELDS || !hasInternalIds(target)) return null;
874
+ const emails = findEmails(target);
875
+ if (emails.length === 0) return null;
876
+ return { reason: "full-record", emailCount: emails.length };
877
+ }
878
+ function detectListPII(target) {
879
+ if (!Array.isArray(target) || target.length < LIST_PII_MIN_ITEMS) return null;
880
+ let itemsWithEmail = 0;
881
+ for (let i = 0; i < Math.min(target.length, PII_SCAN_ARRAY_LIMIT); i++) {
882
+ const item = target[i];
883
+ if (item && typeof item === "object" && findEmails(item).length > 0) {
884
+ itemsWithEmail++;
785
885
  }
786
886
  }
787
- if (Array.isArray(target) && target.length >= LIST_PII_MIN_ITEMS) {
788
- let itemsWithEmail = 0;
789
- for (let i = 0; i < Math.min(target.length, 10); i++) {
790
- const item = target[i];
791
- if (item && typeof item === "object") {
792
- const emails = findEmails(item);
793
- if (emails.length > 0) itemsWithEmail++;
794
- }
795
- }
796
- if (itemsWithEmail >= LIST_PII_MIN_ITEMS) {
797
- const first = target[0];
798
- if (hasInternalIds(first) || topLevelFieldCount(first) >= FULL_RECORD_MIN_FIELDS) {
799
- return { reason: "list-pii", emailCount: itemsWithEmail };
800
- }
801
- }
887
+ if (itemsWithEmail < LIST_PII_MIN_ITEMS) return null;
888
+ const first = target[0];
889
+ if (hasInternalIds(first) || topLevelFieldCount(first) >= FULL_RECORD_MIN_FIELDS) {
890
+ return { reason: "list-pii", emailCount: itemsWithEmail };
802
891
  }
803
892
  return null;
804
893
  }
894
+ function detectPII(method, reqBody, resBody) {
895
+ const target = unwrapResponse(resBody);
896
+ return detectEchoPII(method, reqBody, target) ?? detectFullRecordPII(target) ?? detectListPII(target);
897
+ }
805
898
  var REASON_LABELS = {
806
899
  echo: "echoes back PII from the request body",
807
900
  "full-record": "returns a full record with email and internal IDs",
@@ -816,10 +909,10 @@ var responsePiiLeakRule = {
816
909
  const findings = [];
817
910
  const seen = /* @__PURE__ */ new Map();
818
911
  for (const r of ctx.requests) {
819
- if (r.statusCode >= 400) continue;
820
- const resJson = tryParseJson2(r.responseBody);
912
+ if (isErrorStatus(r.statusCode)) continue;
913
+ const resJson = ctx.parsedBodies.response.get(r.id);
821
914
  if (!resJson) continue;
822
- const reqJson = tryParseJson2(r.requestBody);
915
+ const reqJson = ctx.parsedBodies.request.get(r.id) ?? null;
823
916
  const detection = detectPII(r.method, reqJson, resJson);
824
917
  if (!detection) continue;
825
918
  const ep = `${r.method} ${r.path}`;
@@ -846,12 +939,31 @@ var responsePiiLeakRule = {
846
939
  };
847
940
 
848
941
  // src/analysis/rules/scanner.ts
942
+ function buildBodyCache(requests) {
943
+ const response = /* @__PURE__ */ new Map();
944
+ const request = /* @__PURE__ */ new Map();
945
+ for (const r of requests) {
946
+ if (r.responseBody) {
947
+ const parsed = tryParseJson(r.responseBody);
948
+ if (parsed != null) response.set(r.id, parsed);
949
+ }
950
+ if (r.requestBody) {
951
+ const parsed = tryParseJson(r.requestBody);
952
+ if (parsed != null) request.set(r.id, parsed);
953
+ }
954
+ }
955
+ return { response, request };
956
+ }
849
957
  var SecurityScanner = class {
850
958
  rules = [];
851
959
  register(rule) {
852
960
  this.rules.push(rule);
853
961
  }
854
- scan(ctx) {
962
+ scan(input) {
963
+ const ctx = {
964
+ ...input,
965
+ parsedBodies: buildBodyCache(input.requests)
966
+ };
855
967
  const findings = [];
856
968
  for (const rule of this.rules) {
857
969
  try {
@@ -893,6 +1005,41 @@ var SubscriptionBag = class {
893
1005
  // src/analysis/group.ts
894
1006
  import { randomUUID } from "crypto";
895
1007
 
1008
+ // src/constants/routes.ts
1009
+ var DASHBOARD_PREFIX = "/__brakit";
1010
+ var DASHBOARD_API_REQUESTS = `${DASHBOARD_PREFIX}/api/requests`;
1011
+ var DASHBOARD_API_EVENTS = `${DASHBOARD_PREFIX}/api/events`;
1012
+ var DASHBOARD_API_FLOWS = `${DASHBOARD_PREFIX}/api/flows`;
1013
+ var DASHBOARD_API_CLEAR = `${DASHBOARD_PREFIX}/api/clear`;
1014
+ var DASHBOARD_API_LOGS = `${DASHBOARD_PREFIX}/api/logs`;
1015
+ var DASHBOARD_API_FETCHES = `${DASHBOARD_PREFIX}/api/fetches`;
1016
+ var DASHBOARD_API_ERRORS = `${DASHBOARD_PREFIX}/api/errors`;
1017
+ var DASHBOARD_API_QUERIES = `${DASHBOARD_PREFIX}/api/queries`;
1018
+ var DASHBOARD_API_INGEST = `${DASHBOARD_PREFIX}/api/ingest`;
1019
+ var DASHBOARD_API_METRICS = `${DASHBOARD_PREFIX}/api/metrics`;
1020
+ var DASHBOARD_API_ACTIVITY = `${DASHBOARD_PREFIX}/api/activity`;
1021
+ var DASHBOARD_API_METRICS_LIVE = `${DASHBOARD_PREFIX}/api/metrics/live`;
1022
+ var DASHBOARD_API_INSIGHTS = `${DASHBOARD_PREFIX}/api/insights`;
1023
+ var DASHBOARD_API_SECURITY = `${DASHBOARD_PREFIX}/api/security`;
1024
+ var DASHBOARD_API_TAB = `${DASHBOARD_PREFIX}/api/tab`;
1025
+ var DASHBOARD_API_FINDINGS = `${DASHBOARD_PREFIX}/api/findings`;
1026
+ var DASHBOARD_API_FINDINGS_REPORT = `${DASHBOARD_PREFIX}/api/findings/report`;
1027
+ var VALID_TABS_TUPLE = [
1028
+ "overview",
1029
+ "actions",
1030
+ "requests",
1031
+ "fetches",
1032
+ "queries",
1033
+ "errors",
1034
+ "logs",
1035
+ "performance",
1036
+ "security"
1037
+ ];
1038
+ var VALID_TABS = new Set(VALID_TABS_TUPLE);
1039
+
1040
+ // src/constants/network.ts
1041
+ var RECOVERY_WINDOW_MS = 5 * 60 * 1e3;
1042
+
896
1043
  // src/analysis/categorize.ts
897
1044
  function detectCategory(req) {
898
1045
  const { method, url, statusCode, responseHeaders } = req;
@@ -956,7 +1103,7 @@ function labelRequest(req) {
956
1103
  function generateHumanLabel(req, category) {
957
1104
  const effectivePath = getEffectivePath(req);
958
1105
  const endpointName = getEndpointName(effectivePath);
959
- const failed = req.statusCode >= 400;
1106
+ const failed = isErrorStatus(req.statusCode);
960
1107
  switch (category) {
961
1108
  case "auth-handshake":
962
1109
  return "Auth handshake";
@@ -1136,7 +1283,7 @@ function detectWarnings(requests) {
1136
1283
  for (const req of slowRequests) {
1137
1284
  warnings.push(`${req.label} took ${(req.durationMs / 1e3).toFixed(1)}s`);
1138
1285
  }
1139
- const errors = requests.filter((r) => r.statusCode >= 500);
1286
+ const errors = requests.filter((r) => isServerError(r.statusCode));
1140
1287
  for (const req of errors) {
1141
1288
  warnings.push(`${req.label} \u2014 server error (${req.statusCode})`);
1142
1289
  }
@@ -1192,7 +1339,7 @@ function buildFlow(rawRequests) {
1192
1339
  requests,
1193
1340
  startTime,
1194
1341
  totalDurationMs: Math.round(endTime - startTime),
1195
- hasErrors: requests.some((r) => r.statusCode >= 400),
1342
+ hasErrors: requests.some((r) => isErrorStatus(r.statusCode)),
1196
1343
  warnings: detectWarnings(rawRequests),
1197
1344
  sourcePage,
1198
1345
  redundancyPct
@@ -1260,8 +1407,14 @@ function groupBy(items, keyFn) {
1260
1407
  }
1261
1408
 
1262
1409
  // src/utils/endpoint.ts
1410
+ var DYNAMIC_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^\d+$|^[0-9a-f]{12,}$|^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/i;
1411
+ function normalizePath(path) {
1412
+ const qIdx = path.indexOf("?");
1413
+ const pathname = qIdx === -1 ? path : path.slice(0, qIdx);
1414
+ return pathname.split("/").map((seg) => seg && DYNAMIC_SEGMENT_RE.test(seg) ? ":id" : seg).join("/");
1415
+ }
1263
1416
  function getEndpointKey(method, path) {
1264
- return `${method} ${path}`;
1417
+ return `${method} ${normalizePath(path)}`;
1265
1418
  }
1266
1419
  var ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
1267
1420
  function extractEndpointFromDesc(desc) {
@@ -1343,6 +1496,15 @@ function windowByEndpoint(requests) {
1343
1496
  }
1344
1497
  return windowed;
1345
1498
  }
1499
+ function extractActiveEndpoints(requests) {
1500
+ const endpoints = /* @__PURE__ */ new Set();
1501
+ for (const r of requests) {
1502
+ if (!r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))) {
1503
+ endpoints.add(getEndpointKey(r.method, r.path));
1504
+ }
1505
+ }
1506
+ return endpoints;
1507
+ }
1346
1508
  function prepareContext(ctx) {
1347
1509
  const nonStatic = ctx.requests.filter(
1348
1510
  (r) => !r.isStatic && (!r.path || !r.path.startsWith(DASHBOARD_PREFIX))
@@ -1360,7 +1522,7 @@ function prepareContext(ctx) {
1360
1522
  endpointGroups.set(ep, g);
1361
1523
  }
1362
1524
  g.total++;
1363
- if (r.statusCode >= 400) g.errors++;
1525
+ if (isErrorStatus(r.statusCode)) g.errors++;
1364
1526
  g.totalDuration += r.durationMs;
1365
1527
  g.totalSize += r.responseSize ?? 0;
1366
1528
  const reqQueries = queriesByReq.get(r.id) ?? [];
@@ -1780,7 +1942,7 @@ var responseOverfetchRule = {
1780
1942
  const insights = [];
1781
1943
  const seen = /* @__PURE__ */ new Set();
1782
1944
  for (const r of ctx.nonStatic) {
1783
- if (r.statusCode >= 400 || !r.responseBody) continue;
1945
+ if (isErrorStatus(r.statusCode) || !r.responseBody) continue;
1784
1946
  const ep = getEndpointKey(r.method, r.path);
1785
1947
  if (seen.has(ep)) continue;
1786
1948
  let parsed;
@@ -1924,73 +2086,48 @@ function computeInsights(ctx) {
1924
2086
  return createDefaultInsightRunner().run(ctx);
1925
2087
  }
1926
2088
 
1927
- // src/analysis/insight-tracker.ts
1928
- function computeInsightKey(insight) {
1929
- const identifier = extractEndpointFromDesc(insight.desc) ?? insight.title;
1930
- return `${insight.type}:${identifier}`;
1931
- }
1932
- var InsightTracker = class {
1933
- tracked = /* @__PURE__ */ new Map();
1934
- reconcile(current) {
1935
- const currentKeys = /* @__PURE__ */ new Set();
1936
- const now = Date.now();
1937
- for (const insight of current) {
1938
- const key = computeInsightKey(insight);
1939
- currentKeys.add(key);
1940
- const existing = this.tracked.get(key);
1941
- if (existing) {
1942
- existing.insight = insight;
1943
- existing.lastSeenAt = now;
1944
- existing.consecutiveAbsences = 0;
1945
- if (existing.state === "resolved") {
1946
- existing.state = "open";
1947
- existing.resolvedAt = null;
1948
- }
1949
- } else {
1950
- this.tracked.set(key, {
1951
- key,
1952
- state: "open",
1953
- insight,
1954
- firstSeenAt: now,
1955
- lastSeenAt: now,
1956
- resolvedAt: null,
1957
- consecutiveAbsences: 0
1958
- });
1959
- }
1960
- }
1961
- for (const [key, stateful] of this.tracked) {
1962
- if (stateful.state === "open" && !currentKeys.has(stateful.key)) {
1963
- stateful.consecutiveAbsences++;
1964
- if (stateful.consecutiveAbsences >= RESOLVE_AFTER_ABSENCES) {
1965
- stateful.state = "resolved";
1966
- stateful.resolvedAt = now;
1967
- }
1968
- } else if (stateful.state === "resolved" && stateful.resolvedAt !== null && now - stateful.resolvedAt > RESOLVED_INSIGHT_TTL_MS) {
1969
- this.tracked.delete(key);
1970
- }
1971
- }
1972
- return [...this.tracked.values()];
1973
- }
1974
- getAll() {
1975
- return [...this.tracked.values()];
1976
- }
1977
- clear() {
1978
- this.tracked.clear();
1979
- }
1980
- };
2089
+ // src/analysis/issue-mappers.ts
2090
+ function categorizeInsight(type) {
2091
+ if (type === "security") return "security";
2092
+ if (type === "error" || type === "error-hotspot") return "reliability";
2093
+ return "performance";
2094
+ }
2095
+ function insightToIssue(insight) {
2096
+ return {
2097
+ category: categorizeInsight(insight.type),
2098
+ rule: insight.type,
2099
+ severity: insight.severity,
2100
+ title: insight.title,
2101
+ desc: insight.desc,
2102
+ hint: insight.hint,
2103
+ detail: insight.detail,
2104
+ endpoint: extractEndpointFromDesc(insight.desc) ?? void 0,
2105
+ nav: insight.nav
2106
+ };
2107
+ }
2108
+ function securityFindingToIssue(finding) {
2109
+ return {
2110
+ category: "security",
2111
+ rule: finding.rule,
2112
+ severity: finding.severity,
2113
+ title: finding.title,
2114
+ desc: finding.desc,
2115
+ hint: finding.hint,
2116
+ endpoint: finding.endpoint,
2117
+ nav: "security"
2118
+ };
2119
+ }
1981
2120
 
1982
2121
  // src/analysis/engine.ts
1983
2122
  var AnalysisEngine = class {
1984
- constructor(registry, debounceMs = 300) {
2123
+ constructor(registry, debounceMs = ANALYSIS_DEBOUNCE_MS) {
1985
2124
  this.registry = registry;
1986
2125
  this.debounceMs = debounceMs;
1987
2126
  this.scanner = createDefaultScanner();
1988
2127
  }
1989
2128
  scanner;
1990
- insightTracker = new InsightTracker();
1991
2129
  cachedInsights = [];
1992
2130
  cachedFindings = [];
1993
- cachedStatefulInsights = [];
1994
2131
  debounceTimer = null;
1995
2132
  subs = new SubscriptionBag();
1996
2133
  start() {
@@ -2013,12 +2150,6 @@ var AnalysisEngine = class {
2013
2150
  getFindings() {
2014
2151
  return this.cachedFindings;
2015
2152
  }
2016
- getStatefulFindings() {
2017
- return this.registry.has("finding-store") ? this.registry.get("finding-store").getAll() : [];
2018
- }
2019
- getStatefulInsights() {
2020
- return this.cachedStatefulInsights;
2021
- }
2022
2153
  scheduleRecompute() {
2023
2154
  if (this.debounceTimer) return;
2024
2155
  this.debounceTimer = setTimeout(() => {
@@ -2027,20 +2158,14 @@ var AnalysisEngine = class {
2027
2158
  }, this.debounceMs);
2028
2159
  }
2029
2160
  recompute() {
2030
- const requests = this.registry.get("request-store").getAll();
2161
+ const allRequests = this.registry.get("request-store").getAll();
2031
2162
  const queries = this.registry.get("query-store").getAll();
2032
2163
  const errors = this.registry.get("error-store").getAll();
2033
2164
  const logs = this.registry.get("log-store").getAll();
2034
2165
  const fetches = this.registry.get("fetch-store").getAll();
2166
+ const requests = windowByEndpoint(allRequests);
2035
2167
  const flows = groupRequestsIntoFlows(requests);
2036
2168
  this.cachedFindings = this.scanner.scan({ requests, logs });
2037
- if (this.registry.has("finding-store")) {
2038
- const findingStore = this.registry.get("finding-store");
2039
- for (const finding of this.cachedFindings) {
2040
- findingStore.upsert(finding, "passive");
2041
- }
2042
- findingStore.reconcilePassive(this.cachedFindings);
2043
- }
2044
2169
  this.cachedInsights = computeInsights({
2045
2170
  requests,
2046
2171
  queries,
@@ -2050,24 +2175,40 @@ var AnalysisEngine = class {
2050
2175
  previousMetrics: this.registry.get("metrics-store").getAll(),
2051
2176
  securityFindings: this.cachedFindings
2052
2177
  });
2053
- this.cachedStatefulInsights = this.insightTracker.reconcile(this.cachedInsights);
2054
- const update = {
2055
- insights: this.cachedInsights,
2056
- findings: this.cachedFindings,
2057
- statefulFindings: this.getStatefulFindings(),
2058
- statefulInsights: this.cachedStatefulInsights
2059
- };
2060
- this.registry.get("event-bus").emit("analysis:updated", update);
2178
+ if (this.registry.has("issue-store")) {
2179
+ const issueStore = this.registry.get("issue-store");
2180
+ for (const finding of this.cachedFindings) {
2181
+ issueStore.upsert(securityFindingToIssue(finding), "passive");
2182
+ }
2183
+ for (const insight of this.cachedInsights) {
2184
+ issueStore.upsert(insightToIssue(insight), "passive");
2185
+ }
2186
+ const currentIssueIds = /* @__PURE__ */ new Set();
2187
+ for (const finding of this.cachedFindings) {
2188
+ currentIssueIds.add(computeIssueId(securityFindingToIssue(finding)));
2189
+ }
2190
+ for (const insight of this.cachedInsights) {
2191
+ currentIssueIds.add(computeIssueId(insightToIssue(insight)));
2192
+ }
2193
+ const activeEndpoints = extractActiveEndpoints(allRequests);
2194
+ issueStore.reconcile(currentIssueIds, activeEndpoints);
2195
+ const update = {
2196
+ insights: this.cachedInsights,
2197
+ findings: this.cachedFindings,
2198
+ issues: issueStore.getAll()
2199
+ };
2200
+ this.registry.get("event-bus").emit("analysis:updated", update);
2201
+ }
2061
2202
  }
2062
2203
  };
2063
2204
 
2064
2205
  // src/index.ts
2065
- var VERSION = "0.8.4";
2206
+ var VERSION = "0.8.6";
2066
2207
  export {
2067
2208
  AdapterRegistry,
2068
2209
  AnalysisEngine,
2069
- FindingStore,
2070
2210
  InsightRunner,
2211
+ IssueStore,
2071
2212
  SecurityScanner,
2072
2213
  VERSION,
2073
2214
  computeInsights,