@flrande/bak-extension 0.3.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.bak-e2e-build-stamp +1 -1
- package/dist/background.global.js +1305 -140
- package/dist/content.global.js +820 -3
- package/dist/manifest.json +2 -2
- package/package.json +2 -2
- package/public/manifest.json +2 -2
- package/src/background.ts +1762 -992
- package/src/content.ts +2419 -1593
- package/src/network-debugger.ts +495 -0
- package/src/privacy.ts +112 -1
- package/src/session-binding-storage.ts +68 -0
- package/src/workspace.ts +912 -917
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import type { NetworkEntry } from '@flrande/bak-protocol';
|
|
2
|
+
import { redactHeaderMap, redactTransportText } from './privacy.js';
|
|
3
|
+
|
|
4
|
+
const DEBUGGER_VERSION = '1.3';
|
|
5
|
+
const MAX_ENTRIES = 1000;
|
|
6
|
+
const DEFAULT_BODY_BYTES = 8 * 1024;
|
|
7
|
+
const DEFAULT_TOTAL_BODY_BYTES = 256 * 1024;
|
|
8
|
+
const textEncoder = new TextEncoder();
|
|
9
|
+
const textDecoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
interface DebuggerTarget {
|
|
12
|
+
tabId: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface TabCaptureState {
|
|
16
|
+
attached: boolean;
|
|
17
|
+
attachError: string | null;
|
|
18
|
+
entries: NetworkEntry[];
|
|
19
|
+
entriesById: Map<string, NetworkEntry>;
|
|
20
|
+
requestIdToEntryId: Map<string, string>;
|
|
21
|
+
lastTouchedAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const captures = new Map<number, TabCaptureState>();
|
|
25
|
+
|
|
26
|
+
function getState(tabId: number): TabCaptureState {
|
|
27
|
+
const existing = captures.get(tabId);
|
|
28
|
+
if (existing) {
|
|
29
|
+
return existing;
|
|
30
|
+
}
|
|
31
|
+
const created: TabCaptureState = {
|
|
32
|
+
attached: false,
|
|
33
|
+
attachError: null,
|
|
34
|
+
entries: [],
|
|
35
|
+
entriesById: new Map(),
|
|
36
|
+
requestIdToEntryId: new Map(),
|
|
37
|
+
lastTouchedAt: Date.now()
|
|
38
|
+
};
|
|
39
|
+
captures.set(tabId, created);
|
|
40
|
+
return created;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function debuggerTarget(tabId: number): DebuggerTarget {
|
|
44
|
+
return { tabId };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function utf8ByteLength(value: string): number {
|
|
48
|
+
return textEncoder.encode(value).byteLength;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncateUtf8(value: string, limit: number): string {
|
|
52
|
+
const encoded = textEncoder.encode(value);
|
|
53
|
+
if (encoded.byteLength <= limit) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
return textDecoder.decode(encoded.subarray(0, limit));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeBase64Utf8(value: string): string {
|
|
60
|
+
const binary = atob(value);
|
|
61
|
+
const bytes = new Uint8Array(binary.length);
|
|
62
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
63
|
+
bytes[index] = binary.charCodeAt(index);
|
|
64
|
+
}
|
|
65
|
+
return textDecoder.decode(bytes);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function truncateText(value: string | undefined, limit = DEFAULT_BODY_BYTES): { text?: string; truncated: boolean; bytes?: number } {
|
|
69
|
+
if (typeof value !== 'string') {
|
|
70
|
+
return { truncated: false };
|
|
71
|
+
}
|
|
72
|
+
const bytes = utf8ByteLength(value);
|
|
73
|
+
if (bytes <= limit) {
|
|
74
|
+
return { text: value, truncated: false, bytes };
|
|
75
|
+
}
|
|
76
|
+
const truncatedText = truncateUtf8(value, limit);
|
|
77
|
+
return { text: truncatedText, truncated: true, bytes };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeHeaders(headers: unknown): Record<string, string> | undefined {
|
|
81
|
+
if (typeof headers !== 'object' || headers === null) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const result: Record<string, string> = {};
|
|
85
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
86
|
+
if (value === undefined || value === null) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
result[String(key)] = Array.isArray(value) ? value.map(String).join(', ') : String(value);
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function headerValue(headers: Record<string, string> | undefined, name: string): string | undefined {
|
|
95
|
+
if (!headers) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const lower = name.toLowerCase();
|
|
99
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
100
|
+
if (key.toLowerCase() === lower) {
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isTextualContentType(contentType: string | undefined): boolean {
|
|
108
|
+
if (!contentType) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const normalized = contentType.toLowerCase();
|
|
112
|
+
return (
|
|
113
|
+
normalized.startsWith('text/') ||
|
|
114
|
+
normalized.includes('json') ||
|
|
115
|
+
normalized.includes('javascript') ||
|
|
116
|
+
normalized.includes('xml') ||
|
|
117
|
+
normalized.includes('html') ||
|
|
118
|
+
normalized.includes('urlencoded') ||
|
|
119
|
+
normalized.includes('graphql')
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pushEntry(state: TabCaptureState, entry: NetworkEntry, requestId: string): void {
|
|
124
|
+
state.entries.push(entry);
|
|
125
|
+
state.entriesById.set(entry.id, entry);
|
|
126
|
+
state.requestIdToEntryId.set(requestId, entry.id);
|
|
127
|
+
state.lastTouchedAt = Date.now();
|
|
128
|
+
while (state.entries.length > MAX_ENTRIES) {
|
|
129
|
+
const removed = state.entries.shift();
|
|
130
|
+
if (!removed) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
state.entriesById.delete(removed.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function entryForRequest(tabId: number, requestId: string): NetworkEntry | null {
|
|
138
|
+
const state = captures.get(tabId);
|
|
139
|
+
if (!state) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const entryId = state.requestIdToEntryId.get(requestId);
|
|
143
|
+
if (!entryId) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return state.entriesById.get(entryId) ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function sendDebuggerCommand<T = unknown>(tabId: number, method: string, commandParams?: Record<string, unknown>): Promise<T> {
|
|
150
|
+
return (await chrome.debugger.sendCommand(debuggerTarget(tabId), method, commandParams)) as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function getResponseBodyPreview(tabId: number, requestId: string, contentType: string | undefined): Promise<{
|
|
154
|
+
responseBodyPreview?: string;
|
|
155
|
+
responseBodyTruncated?: boolean;
|
|
156
|
+
binary?: boolean;
|
|
157
|
+
}> {
|
|
158
|
+
try {
|
|
159
|
+
const response = (await sendDebuggerCommand<{
|
|
160
|
+
body?: string;
|
|
161
|
+
base64Encoded?: boolean;
|
|
162
|
+
}>(tabId, 'Network.getResponseBody', { requestId })) ?? { body: '' };
|
|
163
|
+
const rawBody = typeof response.body === 'string' ? response.body : '';
|
|
164
|
+
const base64Encoded = response.base64Encoded === true;
|
|
165
|
+
const binary = base64Encoded && !isTextualContentType(contentType);
|
|
166
|
+
if (binary) {
|
|
167
|
+
return { binary: true };
|
|
168
|
+
}
|
|
169
|
+
const decoded = base64Encoded ? decodeBase64Utf8(rawBody) : rawBody;
|
|
170
|
+
const preview = truncateText(decoded, DEFAULT_BODY_BYTES);
|
|
171
|
+
return {
|
|
172
|
+
responseBodyPreview: preview.text ? redactTransportText(preview.text) : undefined,
|
|
173
|
+
responseBodyTruncated: preview.truncated
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const entry = entryForRequest(tabId, requestId);
|
|
177
|
+
if (entry) {
|
|
178
|
+
entry.failureReason = error instanceof Error ? error.message : String(error);
|
|
179
|
+
}
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function handleLoadingFinished(tabId: number, params: Record<string, unknown>): Promise<void> {
|
|
185
|
+
const requestId = String(params.requestId ?? '');
|
|
186
|
+
const entry = entryForRequest(tabId, requestId);
|
|
187
|
+
if (!entry) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
entry.durationMs = entry.startedAt ? Math.max(0, Date.now() - entry.startedAt) : entry.durationMs;
|
|
191
|
+
if (typeof params.encodedDataLength === 'number') {
|
|
192
|
+
entry.responseBytes = Math.max(0, Math.round(params.encodedDataLength));
|
|
193
|
+
}
|
|
194
|
+
const body = await getResponseBodyPreview(tabId, requestId, entry.contentType);
|
|
195
|
+
Object.assign(entry, body);
|
|
196
|
+
if ((entry.requestBytes ?? 0) + (entry.responseBytes ?? 0) > DEFAULT_TOTAL_BODY_BYTES) {
|
|
197
|
+
entry.truncated = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function upsertRequest(tabId: number, params: Record<string, unknown>): void {
|
|
202
|
+
const state = getState(tabId);
|
|
203
|
+
const requestId = String(params.requestId ?? '');
|
|
204
|
+
if (!requestId) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const request = typeof params.request === 'object' && params.request !== null ? (params.request as Record<string, unknown>) : {};
|
|
208
|
+
const headers = redactHeaderMap(normalizeHeaders(request.headers));
|
|
209
|
+
const truncatedRequest = truncateText(typeof request.postData === 'string' ? request.postData : undefined, DEFAULT_BODY_BYTES);
|
|
210
|
+
const entry: NetworkEntry = {
|
|
211
|
+
id: `net_${tabId}_${requestId}`,
|
|
212
|
+
url: typeof request.url === 'string' ? request.url : '',
|
|
213
|
+
method: typeof request.method === 'string' ? request.method : 'GET',
|
|
214
|
+
status: 0,
|
|
215
|
+
ok: false,
|
|
216
|
+
kind:
|
|
217
|
+
params.type === 'XHR'
|
|
218
|
+
? 'xhr'
|
|
219
|
+
: params.type === 'Fetch'
|
|
220
|
+
? 'fetch'
|
|
221
|
+
: params.type === 'Document'
|
|
222
|
+
? 'navigation'
|
|
223
|
+
: 'resource',
|
|
224
|
+
resourceType: typeof params.type === 'string' ? String(params.type) : undefined,
|
|
225
|
+
ts: Date.now(),
|
|
226
|
+
startedAt: Date.now(),
|
|
227
|
+
durationMs: 0,
|
|
228
|
+
requestBytes: truncatedRequest.bytes,
|
|
229
|
+
requestHeaders: headers,
|
|
230
|
+
requestBodyPreview: truncatedRequest.text ? redactTransportText(truncatedRequest.text) : undefined,
|
|
231
|
+
requestBodyTruncated: truncatedRequest.truncated,
|
|
232
|
+
initiatorUrl:
|
|
233
|
+
typeof params.initiator === 'object' &&
|
|
234
|
+
params.initiator !== null &&
|
|
235
|
+
typeof (params.initiator as Record<string, unknown>).url === 'string'
|
|
236
|
+
? String((params.initiator as Record<string, unknown>).url)
|
|
237
|
+
: undefined,
|
|
238
|
+
tabId,
|
|
239
|
+
source: 'debugger'
|
|
240
|
+
};
|
|
241
|
+
pushEntry(state, entry, requestId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function updateResponse(tabId: number, params: Record<string, unknown>): void {
|
|
245
|
+
const requestId = String(params.requestId ?? '');
|
|
246
|
+
const entry = entryForRequest(tabId, requestId);
|
|
247
|
+
if (!entry) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const response = typeof params.response === 'object' && params.response !== null ? (params.response as Record<string, unknown>) : {};
|
|
251
|
+
const responseHeaders = redactHeaderMap(normalizeHeaders(response.headers));
|
|
252
|
+
entry.status = typeof response.status === 'number' ? Math.round(response.status) : entry.status;
|
|
253
|
+
entry.ok = entry.status >= 200 && entry.status < 400;
|
|
254
|
+
entry.contentType =
|
|
255
|
+
typeof response.mimeType === 'string'
|
|
256
|
+
? response.mimeType
|
|
257
|
+
: headerValue(responseHeaders, 'content-type');
|
|
258
|
+
entry.responseHeaders = responseHeaders;
|
|
259
|
+
if (typeof response.encodedDataLength === 'number') {
|
|
260
|
+
entry.responseBytes = Math.max(0, Math.round(response.encodedDataLength));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function updateFailure(tabId: number, params: Record<string, unknown>): void {
|
|
265
|
+
const requestId = String(params.requestId ?? '');
|
|
266
|
+
const entry = entryForRequest(tabId, requestId);
|
|
267
|
+
if (!entry) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
entry.ok = false;
|
|
271
|
+
entry.failureReason = typeof params.errorText === 'string' ? params.errorText : 'loading failed';
|
|
272
|
+
entry.durationMs = entry.startedAt ? Math.max(0, Date.now() - entry.startedAt) : entry.durationMs;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
chrome.debugger.onEvent.addListener((source, method, params) => {
|
|
276
|
+
const tabId = typeof source.tabId === 'number' ? source.tabId : undefined;
|
|
277
|
+
if (typeof tabId !== 'number') {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const payload = typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {};
|
|
281
|
+
if (method === 'Network.requestWillBeSent') {
|
|
282
|
+
upsertRequest(tabId, payload);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (method === 'Network.responseReceived') {
|
|
286
|
+
updateResponse(tabId, payload);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (method === 'Network.loadingFailed') {
|
|
290
|
+
updateFailure(tabId, payload);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (method === 'Network.loadingFinished') {
|
|
294
|
+
void handleLoadingFinished(tabId, payload);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
chrome.debugger.onDetach.addListener((source, reason) => {
|
|
299
|
+
const tabId = typeof source.tabId === 'number' ? source.tabId : undefined;
|
|
300
|
+
if (typeof tabId !== 'number') {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const state = getState(tabId);
|
|
304
|
+
state.attached = false;
|
|
305
|
+
state.attachError = reason;
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export async function ensureNetworkDebugger(tabId: number): Promise<void> {
|
|
309
|
+
const state = getState(tabId);
|
|
310
|
+
if (state.attached) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await chrome.debugger.attach(debuggerTarget(tabId), DEBUGGER_VERSION);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
state.attachError = error instanceof Error ? error.message : String(error);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
await sendDebuggerCommand(tabId, 'Network.enable');
|
|
320
|
+
state.attached = true;
|
|
321
|
+
state.attachError = null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function networkDebuggerStatus(tabId: number): { attached: boolean; attachError: string | null } {
|
|
325
|
+
const state = getState(tabId);
|
|
326
|
+
return {
|
|
327
|
+
attached: state.attached,
|
|
328
|
+
attachError: state.attachError
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function clearNetworkEntries(tabId: number): void {
|
|
333
|
+
const state = getState(tabId);
|
|
334
|
+
state.entries = [];
|
|
335
|
+
state.entriesById.clear();
|
|
336
|
+
state.requestIdToEntryId.clear();
|
|
337
|
+
state.lastTouchedAt = Date.now();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function entryMatchesFilters(
|
|
341
|
+
entry: NetworkEntry,
|
|
342
|
+
filters: {
|
|
343
|
+
urlIncludes?: string;
|
|
344
|
+
status?: number;
|
|
345
|
+
method?: string;
|
|
346
|
+
}
|
|
347
|
+
): boolean {
|
|
348
|
+
const urlIncludes = typeof filters.urlIncludes === 'string' ? filters.urlIncludes : '';
|
|
349
|
+
const method = typeof filters.method === 'string' ? filters.method.toUpperCase() : '';
|
|
350
|
+
const status = typeof filters.status === 'number' ? filters.status : undefined;
|
|
351
|
+
|
|
352
|
+
if (urlIncludes && !entry.url.includes(urlIncludes)) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (method && entry.method.toUpperCase() !== method) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
if (typeof status === 'number' && entry.status !== status) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function listNetworkEntries(
|
|
365
|
+
tabId: number,
|
|
366
|
+
filters: {
|
|
367
|
+
limit?: number;
|
|
368
|
+
urlIncludes?: string;
|
|
369
|
+
status?: number;
|
|
370
|
+
method?: string;
|
|
371
|
+
} = {}
|
|
372
|
+
): NetworkEntry[] {
|
|
373
|
+
const state = getState(tabId);
|
|
374
|
+
const limit = typeof filters.limit === 'number' ? Math.max(1, Math.min(500, Math.floor(filters.limit))) : 50;
|
|
375
|
+
|
|
376
|
+
return state.entries
|
|
377
|
+
.filter((entry) => entryMatchesFilters(entry, filters))
|
|
378
|
+
.slice(-limit)
|
|
379
|
+
.reverse()
|
|
380
|
+
.map((entry) => ({ ...entry }));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function getNetworkEntry(tabId: number, id: string): NetworkEntry | null {
|
|
384
|
+
const state = getState(tabId);
|
|
385
|
+
const entry = state.entriesById.get(id);
|
|
386
|
+
return entry ? { ...entry } : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function waitForNetworkEntry(
|
|
390
|
+
tabId: number,
|
|
391
|
+
filters: {
|
|
392
|
+
limit?: number;
|
|
393
|
+
urlIncludes?: string;
|
|
394
|
+
status?: number;
|
|
395
|
+
method?: string;
|
|
396
|
+
timeoutMs?: number;
|
|
397
|
+
} = {}
|
|
398
|
+
): Promise<NetworkEntry> {
|
|
399
|
+
const timeoutMs = typeof filters.timeoutMs === 'number' ? Math.max(1, Math.floor(filters.timeoutMs)) : 5000;
|
|
400
|
+
const deadline = Date.now() + timeoutMs;
|
|
401
|
+
const state = getState(tabId);
|
|
402
|
+
const seenIds = new Set(state.entries.filter((entry) => entryMatchesFilters(entry, filters)).map((entry) => entry.id));
|
|
403
|
+
while (Date.now() < deadline) {
|
|
404
|
+
const nextState = getState(tabId);
|
|
405
|
+
const matched = nextState.entries.find((entry) => !seenIds.has(entry.id) && entryMatchesFilters(entry, filters));
|
|
406
|
+
if (matched) {
|
|
407
|
+
return { ...matched };
|
|
408
|
+
}
|
|
409
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
410
|
+
}
|
|
411
|
+
throw {
|
|
412
|
+
code: 'E_TIMEOUT',
|
|
413
|
+
message: 'network.waitFor timeout'
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function searchNetworkEntries(tabId: number, pattern: string, limit = 50): NetworkEntry[] {
|
|
418
|
+
const normalized = pattern.toLowerCase();
|
|
419
|
+
return listNetworkEntries(tabId, { limit: Math.max(limit, 1) }).filter((entry) => {
|
|
420
|
+
const headerText = JSON.stringify({
|
|
421
|
+
requestHeaders: entry.requestHeaders,
|
|
422
|
+
responseHeaders: entry.responseHeaders
|
|
423
|
+
}).toLowerCase();
|
|
424
|
+
return (
|
|
425
|
+
entry.url.toLowerCase().includes(normalized) ||
|
|
426
|
+
(entry.requestBodyPreview ?? '').toLowerCase().includes(normalized) ||
|
|
427
|
+
(entry.responseBodyPreview ?? '').toLowerCase().includes(normalized) ||
|
|
428
|
+
headerText.includes(normalized)
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function latestNetworkTimestamp(tabId: number): number | null {
|
|
434
|
+
const entries = listNetworkEntries(tabId, { limit: MAX_ENTRIES });
|
|
435
|
+
return entries.length > 0 ? entries[0]!.ts : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function recentNetworkSampleIds(tabId: number, limit = 5): string[] {
|
|
439
|
+
return listNetworkEntries(tabId, { limit }).map((entry) => entry.id);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function exportHar(tabId: number, limit = MAX_ENTRIES): Record<string, unknown> {
|
|
443
|
+
const entries = listNetworkEntries(tabId, { limit }).reverse();
|
|
444
|
+
return {
|
|
445
|
+
log: {
|
|
446
|
+
version: '1.2',
|
|
447
|
+
creator: {
|
|
448
|
+
name: 'bak',
|
|
449
|
+
version: '0.6.0'
|
|
450
|
+
},
|
|
451
|
+
entries: entries.map((entry) => ({
|
|
452
|
+
startedDateTime: new Date(entry.startedAt ?? entry.ts).toISOString(),
|
|
453
|
+
time: entry.durationMs,
|
|
454
|
+
request: {
|
|
455
|
+
method: entry.method,
|
|
456
|
+
url: entry.url,
|
|
457
|
+
headers: Object.entries(entry.requestHeaders ?? {}).map(([name, value]) => ({ name, value })),
|
|
458
|
+
postData:
|
|
459
|
+
typeof entry.requestBodyPreview === 'string'
|
|
460
|
+
? {
|
|
461
|
+
mimeType: headerValue(entry.requestHeaders, 'content-type') ?? '',
|
|
462
|
+
text: entry.requestBodyPreview
|
|
463
|
+
}
|
|
464
|
+
: undefined,
|
|
465
|
+
headersSize: -1,
|
|
466
|
+
bodySize: entry.requestBytes ?? -1
|
|
467
|
+
},
|
|
468
|
+
response: {
|
|
469
|
+
status: entry.status,
|
|
470
|
+
statusText: entry.ok ? 'OK' : entry.failureReason ?? '',
|
|
471
|
+
headers: Object.entries(entry.responseHeaders ?? {}).map(([name, value]) => ({ name, value })),
|
|
472
|
+
content: {
|
|
473
|
+
mimeType: entry.contentType ?? '',
|
|
474
|
+
size: entry.responseBytes ?? -1,
|
|
475
|
+
text: entry.binary ? undefined : entry.responseBodyPreview,
|
|
476
|
+
comment: entry.binary ? 'binary body omitted' : undefined
|
|
477
|
+
},
|
|
478
|
+
headersSize: -1,
|
|
479
|
+
bodySize: entry.responseBytes ?? -1
|
|
480
|
+
},
|
|
481
|
+
cache: {},
|
|
482
|
+
timings: {
|
|
483
|
+
send: 0,
|
|
484
|
+
wait: entry.durationMs,
|
|
485
|
+
receive: 0
|
|
486
|
+
},
|
|
487
|
+
_bak: entry
|
|
488
|
+
}))
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function dropNetworkCapture(tabId: number): void {
|
|
494
|
+
captures.delete(tabId);
|
|
495
|
+
}
|
package/src/privacy.ts
CHANGED
|
@@ -16,12 +16,38 @@ export interface NameCandidates {
|
|
|
16
16
|
|
|
17
17
|
const MAX_SAFE_TEXT_LENGTH = 120;
|
|
18
18
|
const MAX_DEBUG_TEXT_LENGTH = 320;
|
|
19
|
+
const REDACTION_MARKER = '[REDACTED]';
|
|
19
20
|
|
|
20
21
|
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
21
22
|
const LONG_DIGIT_PATTERN = /(?:\d[ -]?){13,19}/g;
|
|
22
23
|
const OTP_PATTERN = /^\d{4,8}$/;
|
|
23
24
|
const SECRET_QUERY_PARAM_PATTERN = /(token|secret|password|passwd|otp|code|session|auth)=/i;
|
|
24
25
|
const HIGH_ENTROPY_TOKEN_PATTERN = /^(?=.*\d)(?=.*[a-zA-Z])[A-Za-z0-9~!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`]{16,}$/;
|
|
26
|
+
const TRANSPORT_SECRET_KEY_SOURCE =
|
|
27
|
+
'(?:api[_-]?key|authorization|auth|cookie|csrf(?:token)?|nonce|password|passwd|secret|session(?:id)?|token|xsrf(?:token)?)';
|
|
28
|
+
const TRANSPORT_SECRET_PAIR_PATTERN = new RegExp(`((?:^|[?&;,\\s])${TRANSPORT_SECRET_KEY_SOURCE}=)[^&\\r\\n"'>]*`, 'gi');
|
|
29
|
+
const JSON_SECRET_VALUE_PATTERN = new RegExp(
|
|
30
|
+
`((?:"|')${TRANSPORT_SECRET_KEY_SOURCE}(?:"|')\\s*:\\s*)(?:"[^"]*"|'[^']*'|true|false|null|-?\\d+(?:\\.\\d+)?)`,
|
|
31
|
+
'gi'
|
|
32
|
+
);
|
|
33
|
+
const ASSIGNMENT_SECRET_VALUE_PATTERN = new RegExp(
|
|
34
|
+
`((?:^|[\\s,{;])${TRANSPORT_SECRET_KEY_SOURCE}\\s*[:=]\\s*)([^,&;}"'\\r\\n]+)`,
|
|
35
|
+
'gi'
|
|
36
|
+
);
|
|
37
|
+
const AUTHORIZATION_VALUE_PATTERN = /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+\b/gi;
|
|
38
|
+
const SENSITIVE_ATTRIBUTE_PATTERN = /(?:api[_-]?key|authorization|auth|cookie|csrf|nonce|password|passwd|secret|session|token|xsrf)/i;
|
|
39
|
+
const SENSITIVE_HEADER_PATTERNS = [
|
|
40
|
+
/^authorization$/i,
|
|
41
|
+
/^proxy-authorization$/i,
|
|
42
|
+
/^cookie$/i,
|
|
43
|
+
/^set-cookie$/i,
|
|
44
|
+
/^x-csrf-token$/i,
|
|
45
|
+
/^x-xsrf-token$/i,
|
|
46
|
+
/^csrf-token$/i,
|
|
47
|
+
/^x-auth-token$/i,
|
|
48
|
+
/^x-api-key$/i,
|
|
49
|
+
/^api-key$/i
|
|
50
|
+
];
|
|
25
51
|
|
|
26
52
|
const INPUT_TEXT_ENTRY_TYPES = new Set([
|
|
27
53
|
'text',
|
|
@@ -67,13 +93,41 @@ function redactByPattern(text: string): string {
|
|
|
67
93
|
return '[REDACTED:otp]';
|
|
68
94
|
}
|
|
69
95
|
|
|
70
|
-
if (HIGH_ENTROPY_TOKEN_PATTERN.test(output) && !output.includes('
|
|
96
|
+
if (HIGH_ENTROPY_TOKEN_PATTERN.test(output) && !/[ =&:]/.test(output) && !output.includes('[REDACTED')) {
|
|
71
97
|
return '[REDACTED:secret]';
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
return output;
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
function redactTransportSecrets(text: string): string {
|
|
104
|
+
let output = text;
|
|
105
|
+
output = output.replace(AUTHORIZATION_VALUE_PATTERN, '$1 [REDACTED]');
|
|
106
|
+
output = output.replace(TRANSPORT_SECRET_PAIR_PATTERN, '$1[REDACTED]');
|
|
107
|
+
output = output.replace(JSON_SECRET_VALUE_PATTERN, '$1"[REDACTED]"');
|
|
108
|
+
output = output.replace(ASSIGNMENT_SECRET_VALUE_PATTERN, '$1[REDACTED]');
|
|
109
|
+
|
|
110
|
+
if (HIGH_ENTROPY_TOKEN_PATTERN.test(output) && !/[ =&:]/.test(output) && !output.includes('[REDACTED')) {
|
|
111
|
+
return '[REDACTED:secret]';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function shouldRedactHeader(name: string): boolean {
|
|
118
|
+
return SENSITIVE_HEADER_PATTERNS.some((pattern) => pattern.test(name));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function redactAttributeValue(name: string, value: string): string {
|
|
122
|
+
if (!value) {
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
if (name === 'value') {
|
|
126
|
+
return REDACTION_MARKER;
|
|
127
|
+
}
|
|
128
|
+
return redactTransportText(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
export function redactElementText(raw: string | null | undefined, options: RedactTextOptions = {}): string {
|
|
78
132
|
if (!raw) {
|
|
79
133
|
return '';
|
|
@@ -88,6 +142,63 @@ export function redactElementText(raw: string | null | undefined, options: Redac
|
|
|
88
142
|
return clamp(redacted, options);
|
|
89
143
|
}
|
|
90
144
|
|
|
145
|
+
export function containsRedactionMarker(raw: string | null | undefined): boolean {
|
|
146
|
+
return typeof raw === 'string' && raw.includes('[REDACTED');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function redactTransportText(raw: string | null | undefined): string {
|
|
150
|
+
if (!raw) {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
return redactTransportSecrets(String(raw));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function redactHeaderMap(headers: Record<string, string> | undefined): Record<string, string> | undefined {
|
|
157
|
+
if (!headers) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
const result: Record<string, string> = {};
|
|
161
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
162
|
+
result[name] = shouldRedactHeader(name) ? `[REDACTED:${name.toLowerCase()}]` : redactTransportText(value);
|
|
163
|
+
}
|
|
164
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function redactHtmlSnapshot(root: Element | null | undefined): string {
|
|
168
|
+
if (!root || !('cloneNode' in root)) {
|
|
169
|
+
return '';
|
|
170
|
+
}
|
|
171
|
+
const clone = root.cloneNode(true) as Element;
|
|
172
|
+
const elements = [clone, ...Array.from(clone.querySelectorAll('*'))];
|
|
173
|
+
for (const element of elements) {
|
|
174
|
+
const tagName = element.tagName.toLowerCase();
|
|
175
|
+
if (tagName === 'script' && !element.getAttribute('src')) {
|
|
176
|
+
element.textContent = '[REDACTED:script]';
|
|
177
|
+
}
|
|
178
|
+
if (tagName === 'textarea' && (element.textContent ?? '').trim().length > 0) {
|
|
179
|
+
element.textContent = REDACTION_MARKER;
|
|
180
|
+
}
|
|
181
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
182
|
+
const name = attribute.name;
|
|
183
|
+
const value = attribute.value;
|
|
184
|
+
const shouldRedactValue =
|
|
185
|
+
(name === 'value' && (tagName === 'input' || tagName === 'textarea' || tagName === 'option')) ||
|
|
186
|
+
SENSITIVE_ATTRIBUTE_PATTERN.test(name);
|
|
187
|
+
if (shouldRedactValue) {
|
|
188
|
+
element.setAttribute(name, redactAttributeValue(name, value));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (name === 'href' || name === 'src' || name === 'action' || name === 'content' || name.startsWith('data-')) {
|
|
192
|
+
const redacted = redactAttributeValue(name, value);
|
|
193
|
+
if (redacted !== value) {
|
|
194
|
+
element.setAttribute(name, redacted);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return 'outerHTML' in clone ? (clone as HTMLElement).outerHTML : '';
|
|
200
|
+
}
|
|
201
|
+
|
|
91
202
|
function isTextEntryField(candidates: NameCandidates): boolean {
|
|
92
203
|
const tag = candidates.tag.toLowerCase();
|
|
93
204
|
const role = (candidates.role ?? '').toLowerCase();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { WorkspaceRecord } from './workspace.js';
|
|
2
|
+
|
|
3
|
+
export const STORAGE_KEY_SESSION_BINDINGS = 'sessionBindings';
|
|
4
|
+
export const LEGACY_STORAGE_KEY_WORKSPACES = 'agentWorkspaces';
|
|
5
|
+
export const LEGACY_STORAGE_KEY_WORKSPACE = 'agentWorkspace';
|
|
6
|
+
|
|
7
|
+
function isWorkspaceRecord(value: unknown): value is WorkspaceRecord {
|
|
8
|
+
if (typeof value !== 'object' || value === null) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const candidate = value as Record<string, unknown>;
|
|
12
|
+
return (
|
|
13
|
+
typeof candidate.id === 'string' &&
|
|
14
|
+
Array.isArray(candidate.tabIds) &&
|
|
15
|
+
(typeof candidate.windowId === 'number' || candidate.windowId === null) &&
|
|
16
|
+
(typeof candidate.groupId === 'number' || candidate.groupId === null) &&
|
|
17
|
+
(typeof candidate.activeTabId === 'number' || candidate.activeTabId === null) &&
|
|
18
|
+
(typeof candidate.primaryTabId === 'number' || candidate.primaryTabId === null)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cloneWorkspaceRecord(state: WorkspaceRecord): WorkspaceRecord {
|
|
23
|
+
return {
|
|
24
|
+
...state,
|
|
25
|
+
tabIds: [...state.tabIds]
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeWorkspaceRecordMap(value: unknown): { found: boolean; map: Record<string, WorkspaceRecord> } {
|
|
30
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
31
|
+
return {
|
|
32
|
+
found: false,
|
|
33
|
+
map: {}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const normalizedEntries: Array<readonly [string, WorkspaceRecord]> = [];
|
|
37
|
+
for (const [workspaceId, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
38
|
+
if (!isWorkspaceRecord(entry)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
normalizedEntries.push([workspaceId, cloneWorkspaceRecord(entry)] as const);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
found: true,
|
|
45
|
+
map: Object.fromEntries(normalizedEntries)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveSessionBindingStateMap(stored: Record<string, unknown>): Record<string, WorkspaceRecord> {
|
|
50
|
+
const current = normalizeWorkspaceRecordMap(stored[STORAGE_KEY_SESSION_BINDINGS]);
|
|
51
|
+
if (current.found) {
|
|
52
|
+
return current.map;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const legacyMap = normalizeWorkspaceRecordMap(stored[LEGACY_STORAGE_KEY_WORKSPACES]);
|
|
56
|
+
if (legacyMap.found) {
|
|
57
|
+
return legacyMap.map;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const legacySingle = stored[LEGACY_STORAGE_KEY_WORKSPACE];
|
|
61
|
+
if (isWorkspaceRecord(legacySingle)) {
|
|
62
|
+
return {
|
|
63
|
+
[legacySingle.id]: cloneWorkspaceRecord(legacySingle)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {};
|
|
68
|
+
}
|