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/README.md +3 -3
- package/dist/api.d.ts +133 -111
- package/dist/api.js +468 -327
- package/dist/bin/brakit.js +864 -448
- package/dist/dashboard.html +2653 -0
- package/dist/mcp/server.js +248 -158
- package/dist/runtime/index.js +1357 -783
- package/package.json +3 -2
package/dist/api.js
CHANGED
|
@@ -1,12 +1,94 @@
|
|
|
1
|
-
// src/store/
|
|
2
|
-
import {
|
|
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/
|
|
6
|
-
|
|
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
|
|
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 =
|
|
39
|
-
var
|
|
40
|
-
var
|
|
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
|
|
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
|
|
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 (!
|
|
180
|
+
if (!await fileExists(this.opts.dir)) {
|
|
148
181
|
await mkdir(this.opts.dir, { recursive: true });
|
|
149
182
|
if (this.opts.gitignoreEntry) {
|
|
150
|
-
|
|
183
|
+
await ensureGitignoreAsync(this.opts.dir, this.opts.gitignoreEntry);
|
|
151
184
|
}
|
|
152
185
|
}
|
|
153
186
|
}
|
|
154
187
|
};
|
|
155
188
|
|
|
156
|
-
// src/
|
|
157
|
-
import { createHash } from "crypto";
|
|
158
|
-
function
|
|
159
|
-
const
|
|
160
|
-
|
|
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/
|
|
164
|
-
var
|
|
165
|
-
constructor(
|
|
166
|
-
this.
|
|
167
|
-
|
|
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:
|
|
171
|
-
filePath: this.
|
|
172
|
-
|
|
173
|
-
label: "findings"
|
|
203
|
+
dir: dataDir,
|
|
204
|
+
filePath: this.issuesPath,
|
|
205
|
+
label: "issues"
|
|
174
206
|
});
|
|
175
|
-
this.load();
|
|
176
207
|
}
|
|
177
|
-
|
|
208
|
+
issues = /* @__PURE__ */ new Map();
|
|
178
209
|
flushTimer = null;
|
|
179
210
|
dirty = false;
|
|
180
211
|
writer;
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
197
|
-
const id =
|
|
198
|
-
const existing = this.
|
|
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.
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
245
|
+
issueId: id,
|
|
213
246
|
state: "open",
|
|
214
247
|
source,
|
|
215
|
-
|
|
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.
|
|
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
|
|
263
|
+
* Reconcile issues against the current analysis results using evidence-based resolution.
|
|
237
264
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
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
|
-
|
|
245
|
-
const
|
|
246
|
-
for (const [
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
319
|
+
return [...this.issues.values()];
|
|
256
320
|
}
|
|
257
321
|
getByState(state) {
|
|
258
|
-
return [...this.
|
|
322
|
+
return [...this.issues.values()].filter((i) => i.state === state);
|
|
259
323
|
}
|
|
260
|
-
|
|
261
|
-
return this.
|
|
324
|
+
getByCategory(category) {
|
|
325
|
+
return [...this.issues.values()].filter((i) => i.category === category);
|
|
262
326
|
}
|
|
263
|
-
|
|
264
|
-
this.
|
|
265
|
-
this.dirty = true;
|
|
327
|
+
get(issueId) {
|
|
328
|
+
return this.issues.get(issueId);
|
|
266
329
|
}
|
|
267
|
-
|
|
330
|
+
clear() {
|
|
331
|
+
this.issues.clear();
|
|
332
|
+
this.dirty = false;
|
|
268
333
|
try {
|
|
269
|
-
if (existsSync3(this.
|
|
270
|
-
|
|
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:
|
|
294
|
-
|
|
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
|
|
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 =
|
|
313
|
-
const raw = await
|
|
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 ?
|
|
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(
|
|
326
|
-
if (await fileExists(
|
|
327
|
-
if (await fileExists(
|
|
328
|
-
if (await fileExists(
|
|
329
|
-
if (await fileExists(
|
|
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_
|
|
379
|
-
var LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_
|
|
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/
|
|
398
|
-
function
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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,
|
|
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 >=
|
|
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
|
|
436
|
-
const parsed =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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 (
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
820
|
-
const resJson =
|
|
912
|
+
if (isErrorStatus(r.statusCode)) continue;
|
|
913
|
+
const resJson = ctx.parsedBodies.response.get(r.id);
|
|
821
914
|
if (!resJson) continue;
|
|
822
|
-
const reqJson =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
1928
|
-
function
|
|
1929
|
-
|
|
1930
|
-
return
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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.
|
|
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,
|