browsertrack 0.0.1 → 0.1.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/README.md +143 -0
- package/dist/chunk-6A7FFDIB.js +221 -0
- package/dist/chunk-6A7FFDIB.js.map +1 -0
- package/dist/chunk-ANMR5WBH.js +659 -0
- package/dist/chunk-ANMR5WBH.js.map +1 -0
- package/dist/chunk-BSKNG4V7.js +1199 -0
- package/dist/chunk-BSKNG4V7.js.map +1 -0
- package/dist/chunk-X7C5CHBO.js +2080 -0
- package/dist/chunk-X7C5CHBO.js.map +1 -0
- package/dist/chunk-ZLNGOOH2.js +554 -0
- package/dist/chunk-ZLNGOOH2.js.map +1 -0
- package/dist/cli/index.js +2829 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/index.cjs +2218 -0
- package/dist/client/index.d.ts +197 -0
- package/dist/client/index.js +20 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client.iife.js +309 -0
- package/dist/core/index.d.ts +78 -0
- package/dist/core/index.js +31 -0
- package/dist/core/index.js.map +1 -0
- package/dist/daemon/index.d.ts +58 -0
- package/dist/daemon/index.js +32 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/engine-DKloFjvs.d.ts +157 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +12 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/notes-CBvN91Wf.d.ts +260 -0
- package/dist/projects-CY8ungMt.d.ts +99 -0
- package/dist/server-CN-se8td.d.ts +55 -0
- package/examples/test-app/index.html +299 -0
- package/package.json +52 -7
- package/packages/cli/src/index.ts +413 -0
- package/packages/client/package.json +28 -0
- package/packages/client/src/breadcrumbs.ts +30 -0
- package/packages/client/src/client.ts +278 -0
- package/packages/client/src/commands/handler.ts +301 -0
- package/packages/client/src/config.ts +37 -0
- package/packages/client/src/index.ts +50 -0
- package/packages/client/src/interceptors/console.ts +68 -0
- package/packages/client/src/interceptors/interaction.ts +119 -0
- package/packages/client/src/interceptors/navigation.ts +93 -0
- package/packages/client/src/interceptors/network.ts +165 -0
- package/packages/client/src/interceptors/runtime.ts +65 -0
- package/packages/client/src/notes/inspector.ts +943 -0
- package/packages/client/src/screenshot/browser-script-driver.ts +192 -0
- package/packages/client/src/screenshot/driver.ts +14 -0
- package/packages/client/src/transport/websocket.ts +179 -0
- package/packages/client/tsconfig.json +8 -0
- package/packages/core/dist/index.d.ts +317 -0
- package/packages/core/dist/index.js +221 -0
- package/packages/core/package.json +23 -0
- package/packages/core/src/fingerprint.ts +120 -0
- package/packages/core/src/index.ts +9 -0
- package/packages/core/src/redaction.ts +139 -0
- package/packages/core/src/selector.ts +77 -0
- package/packages/core/src/types/commands.ts +101 -0
- package/packages/core/src/types/events.ts +108 -0
- package/packages/core/src/types/incidents.ts +54 -0
- package/packages/core/src/types/notes.ts +106 -0
- package/packages/core/src/types/probes.ts +44 -0
- package/packages/core/src/types/projects.ts +20 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/daemon/src/config.ts +29 -0
- package/packages/daemon/src/incidents/engine.ts +211 -0
- package/packages/daemon/src/index.ts +18 -0
- package/packages/daemon/src/notes/engine.ts +92 -0
- package/packages/daemon/src/notes/verification.ts +191 -0
- package/packages/daemon/src/server/daemon.ts +112 -0
- package/packages/daemon/src/server/http.ts +165 -0
- package/packages/daemon/src/server/ws.ts +175 -0
- package/packages/daemon/src/session/manager.ts +126 -0
- package/packages/daemon/src/storage/db.ts +733 -0
- package/packages/daemon/src/storage/screenshot-store.ts +49 -0
- package/packages/daemon/src/verification/engine.ts +264 -0
- package/packages/mcp/src/handlers.ts +398 -0
- package/packages/mcp/src/index.ts +3 -0
- package/packages/mcp/src/server.ts +86 -0
- package/packages/mcp/src/tools.ts +236 -0
- package/src/index.ts +4 -0
- package/test/client/interceptors.test.ts +69 -0
- package/test/core/fingerprint.test.ts +53 -0
- package/test/core/notes.test.ts +66 -0
- package/test/core/redaction.test.ts +48 -0
- package/test/daemon/incident-engine.test.ts +98 -0
- package/test/daemon/notes-storage.test.ts +130 -0
- package/test/daemon/notes-verification.test.ts +135 -0
- package/test/daemon/storage.test.ts +123 -0
- package/test/daemon/verification-engine.test.ts +88 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +153 -0
- package/test/e2e/visual-notes-e2e.test.ts +205 -0
- package/test/mcp/handlers.test.ts +116 -0
- package/test/mcp/notes.test.ts +107 -0
- package/tsconfig.base.json +17 -0
- package/tsconfig.json +26 -0
- package/tsup.config.ts +54 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// src/fingerprint.ts
|
|
2
|
+
function normalizeErrorMessage(message) {
|
|
3
|
+
if (!message) return "unknown_error";
|
|
4
|
+
return message.trim().replace(/0x[0-9a-fA-F]+/g, "0x<HEX>").replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, "<UUID>").replace(/\?[tv]=[\w.-]+/g, "").replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<TIMESTAMP>").replace(/#\d+/g, "#<ID>").replace(/\s+/g, " ");
|
|
5
|
+
}
|
|
6
|
+
function normalizeSourceFile(filename) {
|
|
7
|
+
if (!filename) return "unknown_source";
|
|
8
|
+
let cleaned = filename.trim();
|
|
9
|
+
try {
|
|
10
|
+
if (cleaned.startsWith("http://") || cleaned.startsWith("https://")) {
|
|
11
|
+
const url = new URL(cleaned);
|
|
12
|
+
cleaned = url.pathname;
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
|
|
16
|
+
}
|
|
17
|
+
cleaned = cleaned.split("?")[0].split("#")[0];
|
|
18
|
+
cleaned = cleaned.replace(/\\/g, "/");
|
|
19
|
+
return cleaned || "unknown_source";
|
|
20
|
+
}
|
|
21
|
+
function extractSourceFromStack(stack) {
|
|
22
|
+
if (!stack) return null;
|
|
23
|
+
const lines = stack.split("\n");
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const match = line.match(/(?:at\s+(?:.*?\s+\()?)?(https?:\/\/[^\s)]+|file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?/);
|
|
26
|
+
if (match) {
|
|
27
|
+
return {
|
|
28
|
+
file: normalizeSourceFile(match[1]),
|
|
29
|
+
line: parseInt(match[2], 10),
|
|
30
|
+
column: parseInt(match[3], 10)
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function djb2Hash(str) {
|
|
37
|
+
let hash = 5381;
|
|
38
|
+
for (let i = 0; i < str.length; i++) {
|
|
39
|
+
hash = hash * 33 ^ str.charCodeAt(i);
|
|
40
|
+
}
|
|
41
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
42
|
+
}
|
|
43
|
+
function computeFingerprint(input) {
|
|
44
|
+
const normType = (input.type || "Error").trim().toLowerCase();
|
|
45
|
+
const normMsg = normalizeErrorMessage(input.message);
|
|
46
|
+
let sourceFile = normalizeSourceFile(input.sourceFile);
|
|
47
|
+
let line = input.line || 0;
|
|
48
|
+
if ((sourceFile === "unknown_source" || line === 0) && input.stack) {
|
|
49
|
+
const extracted = extractSourceFromStack(input.stack);
|
|
50
|
+
if (extracted) {
|
|
51
|
+
sourceFile = extracted.file;
|
|
52
|
+
line = extracted.line;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const rawKey = `${normType}::${normMsg}::${sourceFile}::${line}`;
|
|
56
|
+
const hash = djb2Hash(rawKey);
|
|
57
|
+
return `fp_${hash}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/redaction.ts
|
|
61
|
+
var SENSITIVE_KEY_PATTERNS = [
|
|
62
|
+
/^authorization$/i,
|
|
63
|
+
/^cookie$/i,
|
|
64
|
+
/^set-cookie$/i,
|
|
65
|
+
/password/i,
|
|
66
|
+
/token/i,
|
|
67
|
+
/secret/i,
|
|
68
|
+
/apikey/i,
|
|
69
|
+
/api_key/i,
|
|
70
|
+
/access_token/i,
|
|
71
|
+
/refresh_token/i,
|
|
72
|
+
/auth/i,
|
|
73
|
+
/credentials/i,
|
|
74
|
+
/private_key/i,
|
|
75
|
+
/ssn/i,
|
|
76
|
+
/credit_card/i,
|
|
77
|
+
/cvv/i
|
|
78
|
+
];
|
|
79
|
+
var SENSITIVE_QUERY_PARAMS = [
|
|
80
|
+
"token",
|
|
81
|
+
"auth",
|
|
82
|
+
"key",
|
|
83
|
+
"apikey",
|
|
84
|
+
"api_key",
|
|
85
|
+
"secret",
|
|
86
|
+
"password",
|
|
87
|
+
"access_token",
|
|
88
|
+
"refresh_token",
|
|
89
|
+
"code",
|
|
90
|
+
"signature"
|
|
91
|
+
];
|
|
92
|
+
var REDACTED_PLACEHOLDER = "[REDACTED]";
|
|
93
|
+
function isSensitiveKey(key) {
|
|
94
|
+
if (!key) return false;
|
|
95
|
+
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
|
|
96
|
+
}
|
|
97
|
+
function redactUrl(rawUrl) {
|
|
98
|
+
if (!rawUrl) return rawUrl;
|
|
99
|
+
try {
|
|
100
|
+
const isRelative = !rawUrl.startsWith("http://") && !rawUrl.startsWith("https://") && !rawUrl.startsWith("ws://") && !rawUrl.startsWith("wss://");
|
|
101
|
+
const base = "http://localhost";
|
|
102
|
+
const parsed = new URL(rawUrl, base);
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
105
|
+
if (parsed.searchParams.has(param)) {
|
|
106
|
+
parsed.searchParams.set(param, REDACTED_PLACEHOLDER);
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const key of Array.from(parsed.searchParams.keys())) {
|
|
111
|
+
if (isSensitiveKey(key)) {
|
|
112
|
+
parsed.searchParams.set(key, REDACTED_PLACEHOLDER);
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!changed) return rawUrl;
|
|
117
|
+
if (isRelative) {
|
|
118
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
119
|
+
}
|
|
120
|
+
return parsed.toString();
|
|
121
|
+
} catch {
|
|
122
|
+
let safe = rawUrl;
|
|
123
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
124
|
+
const reg = new RegExp(`([?&]${param}=)[^&#]+`, "gi");
|
|
125
|
+
safe = safe.replace(reg, `$1${REDACTED_PLACEHOLDER}`);
|
|
126
|
+
}
|
|
127
|
+
return safe;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function redactHeaders(headers) {
|
|
131
|
+
if (!headers || typeof headers !== "object") return {};
|
|
132
|
+
const safe = {};
|
|
133
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
134
|
+
if (isSensitiveKey(key)) {
|
|
135
|
+
safe[key] = REDACTED_PLACEHOLDER;
|
|
136
|
+
} else {
|
|
137
|
+
safe[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return safe;
|
|
141
|
+
}
|
|
142
|
+
function redactSensitiveData(data, maxDepth = 4, currentDepth = 0) {
|
|
143
|
+
if (data === null || data === void 0) return data;
|
|
144
|
+
if (typeof data !== "object") return data;
|
|
145
|
+
if (currentDepth > maxDepth) return "[DEPTH_EXCEEDED]";
|
|
146
|
+
if (Array.isArray(data)) {
|
|
147
|
+
return data.map((item) => redactSensitiveData(item, maxDepth, currentDepth + 1));
|
|
148
|
+
}
|
|
149
|
+
const result = {};
|
|
150
|
+
for (const [key, value] of Object.entries(data)) {
|
|
151
|
+
if (isSensitiveKey(key)) {
|
|
152
|
+
result[key] = REDACTED_PLACEHOLDER;
|
|
153
|
+
} else if (typeof value === "string") {
|
|
154
|
+
if (value.startsWith("http://") || value.startsWith("https://") || value.includes("?")) {
|
|
155
|
+
result[key] = redactUrl(value);
|
|
156
|
+
} else {
|
|
157
|
+
result[key] = value;
|
|
158
|
+
}
|
|
159
|
+
} else if (typeof value === "object" && value !== null) {
|
|
160
|
+
result[key] = redactSensitiveData(value, maxDepth, currentDepth + 1);
|
|
161
|
+
} else {
|
|
162
|
+
result[key] = value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/selector.ts
|
|
169
|
+
function getSemanticSelector(element, options = {}) {
|
|
170
|
+
if (!element || !element.tagName) return "unknown";
|
|
171
|
+
const maxClasses = options.maxClasses ?? 2;
|
|
172
|
+
const testId = element.getAttribute("data-testid") || element.getAttribute("data-test") || element.getAttribute("data-cy") || element.getAttribute("data-qa");
|
|
173
|
+
if (testId) {
|
|
174
|
+
return `[data-testid="${testId}"]`;
|
|
175
|
+
}
|
|
176
|
+
const id = element.id;
|
|
177
|
+
if (id && !/^[0-9]+$/.test(id) && !/[0-9a-f]{8}-[0-9a-f]{4}/i.test(id)) {
|
|
178
|
+
return `#${id}`;
|
|
179
|
+
}
|
|
180
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
181
|
+
if (ariaLabel && ariaLabel.length < 30) {
|
|
182
|
+
return `${element.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`;
|
|
183
|
+
}
|
|
184
|
+
const nameAttr = element.getAttribute("name");
|
|
185
|
+
if (nameAttr) {
|
|
186
|
+
return `${element.tagName.toLowerCase()}[name="${nameAttr}"]`;
|
|
187
|
+
}
|
|
188
|
+
const tag = element.tagName.toLowerCase();
|
|
189
|
+
const classList = Array.from(element.classList || []).filter((c) => !c.startsWith("css-") && !c.startsWith("_") && !/^[a-z0-9]{5,}$/i.test(c)).slice(0, maxClasses);
|
|
190
|
+
if (classList.length > 0) {
|
|
191
|
+
const classStr = classList.map((c) => `.${c}`).join("");
|
|
192
|
+
return `${tag}${classStr}`;
|
|
193
|
+
}
|
|
194
|
+
if (element.parentElement && element.parentElement !== document.body && element.parentElement !== document.documentElement) {
|
|
195
|
+
const parentSelector = getSemanticSelector(element.parentElement, { maxClasses: 1 });
|
|
196
|
+
if (parentSelector && parentSelector !== "unknown" && !parentSelector.includes(">")) {
|
|
197
|
+
return `${parentSelector} > ${tag}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return tag;
|
|
201
|
+
}
|
|
202
|
+
function truncate(str, maxLength = 200) {
|
|
203
|
+
if (!str) return "";
|
|
204
|
+
if (str.length <= maxLength) return str;
|
|
205
|
+
return str.slice(0, maxLength) + "...";
|
|
206
|
+
}
|
|
207
|
+
export {
|
|
208
|
+
REDACTED_PLACEHOLDER,
|
|
209
|
+
SENSITIVE_KEY_PATTERNS,
|
|
210
|
+
SENSITIVE_QUERY_PARAMS,
|
|
211
|
+
computeFingerprint,
|
|
212
|
+
extractSourceFromStack,
|
|
213
|
+
getSemanticSelector,
|
|
214
|
+
isSensitiveKey,
|
|
215
|
+
normalizeErrorMessage,
|
|
216
|
+
normalizeSourceFile,
|
|
217
|
+
redactHeaders,
|
|
218
|
+
redactSensitiveData,
|
|
219
|
+
redactUrl,
|
|
220
|
+
truncate
|
|
221
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@browserdiag/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core types, schemas, fingerprinting, and utilities for browserdiag",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
16
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
17
|
+
"clean": "rm -rf dist"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"tsup": "^8.3.6",
|
|
21
|
+
"typescript": "^5.7.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error fingerprinting and normalization utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface FingerprintInput {
|
|
6
|
+
type: string;
|
|
7
|
+
message: string;
|
|
8
|
+
sourceFile?: string;
|
|
9
|
+
line?: number;
|
|
10
|
+
column?: number;
|
|
11
|
+
stack?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalizes error messages by stripping dynamic content like UUIDs, timestamps,
|
|
16
|
+
* hex addresses, and file timestamps.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeErrorMessage(message: string): string {
|
|
19
|
+
if (!message) return 'unknown_error';
|
|
20
|
+
|
|
21
|
+
return message
|
|
22
|
+
// Normalize string representation
|
|
23
|
+
.trim()
|
|
24
|
+
// Replace hex addresses (e.g. 0x7ffee4b2a8)
|
|
25
|
+
.replace(/0x[0-9a-fA-F]+/g, '0x<HEX>')
|
|
26
|
+
// Replace UUIDs
|
|
27
|
+
.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '<UUID>')
|
|
28
|
+
// Replace timestamp query params like ?t=1719283748 or ?v=1.2.3
|
|
29
|
+
.replace(/\?[tv]=[\w.-]+/g, '')
|
|
30
|
+
// Replace ISO timestamps
|
|
31
|
+
.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, '<TIMESTAMP>')
|
|
32
|
+
// Replace numbers in quotes/brackets if likely IDs (e.g., user "12345")
|
|
33
|
+
.replace(/#\d+/g, '#<ID>')
|
|
34
|
+
// Normalize whitespaces
|
|
35
|
+
.replace(/\s+/g, ' ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalizes source filenames by removing origin and cache-busting query strings.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeSourceFile(filename?: string): string {
|
|
42
|
+
if (!filename) return 'unknown_source';
|
|
43
|
+
|
|
44
|
+
let cleaned = filename.trim();
|
|
45
|
+
// Strip protocol and origin if present (e.g. http://localhost:5173/src/App.tsx?t=123)
|
|
46
|
+
try {
|
|
47
|
+
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
|
|
48
|
+
const url = new URL(cleaned);
|
|
49
|
+
cleaned = url.pathname;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// fallback string manipulation
|
|
53
|
+
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Remove query parameters
|
|
57
|
+
cleaned = cleaned.split('?')[0].split('#')[0];
|
|
58
|
+
|
|
59
|
+
// Normalize windows path separators
|
|
60
|
+
cleaned = cleaned.replace(/\\/g, '/');
|
|
61
|
+
|
|
62
|
+
return cleaned || 'unknown_source';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extracts source location from stack if not explicitly provided
|
|
67
|
+
*/
|
|
68
|
+
export function extractSourceFromStack(stack?: string): { file: string; line: number; column?: number } | null {
|
|
69
|
+
if (!stack) return null;
|
|
70
|
+
|
|
71
|
+
const lines = stack.split('\n');
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
// Match standard stack line: at functionName (http://localhost:5173/src/main.tsx:15:3) or at http://...:15:3
|
|
74
|
+
const match = line.match(/(?:at\s+(?:.*?\s+\()?)?(https?:\/\/[^\s)]+|file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?/);
|
|
75
|
+
if (match) {
|
|
76
|
+
return {
|
|
77
|
+
file: normalizeSourceFile(match[1]),
|
|
78
|
+
line: parseInt(match[2], 10),
|
|
79
|
+
column: parseInt(match[3], 10),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fast synchronous djb2-based hash for universal client/server runtime
|
|
88
|
+
*/
|
|
89
|
+
function djb2Hash(str: string): string {
|
|
90
|
+
let hash = 5381;
|
|
91
|
+
for (let i = 0; i < str.length; i++) {
|
|
92
|
+
hash = (hash * 33) ^ str.charCodeAt(i);
|
|
93
|
+
}
|
|
94
|
+
// Convert to unsigned 32-bit hex string
|
|
95
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Computes a deterministic fingerprint for an error
|
|
100
|
+
*/
|
|
101
|
+
export function computeFingerprint(input: FingerprintInput): string {
|
|
102
|
+
const normType = (input.type || 'Error').trim().toLowerCase();
|
|
103
|
+
const normMsg = normalizeErrorMessage(input.message);
|
|
104
|
+
|
|
105
|
+
let sourceFile = normalizeSourceFile(input.sourceFile);
|
|
106
|
+
let line = input.line || 0;
|
|
107
|
+
|
|
108
|
+
if ((sourceFile === 'unknown_source' || line === 0) && input.stack) {
|
|
109
|
+
const extracted = extractSourceFromStack(input.stack);
|
|
110
|
+
if (extracted) {
|
|
111
|
+
sourceFile = extracted.file;
|
|
112
|
+
line = extracted.line;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawKey = `${normType}::${normMsg}::${sourceFile}::${line}`;
|
|
117
|
+
const hash = djb2Hash(rawKey);
|
|
118
|
+
|
|
119
|
+
return `fp_${hash}`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types/events.js';
|
|
2
|
+
export * from './types/incidents.js';
|
|
3
|
+
export * from './types/commands.js';
|
|
4
|
+
export * from './types/probes.js';
|
|
5
|
+
export * from './types/projects.js';
|
|
6
|
+
export * from './types/notes.js';
|
|
7
|
+
export * from './fingerprint.js';
|
|
8
|
+
export * from './redaction.js';
|
|
9
|
+
export * from './selector.js';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security and Redaction Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const SENSITIVE_KEY_PATTERNS = [
|
|
6
|
+
/^authorization$/i,
|
|
7
|
+
/^cookie$/i,
|
|
8
|
+
/^set-cookie$/i,
|
|
9
|
+
/password/i,
|
|
10
|
+
/token/i,
|
|
11
|
+
/secret/i,
|
|
12
|
+
/api[-_]?key/i,
|
|
13
|
+
/access[-_]?token/i,
|
|
14
|
+
/refresh[-_]?token/i,
|
|
15
|
+
/credentials/i,
|
|
16
|
+
/private[-_]?key/i,
|
|
17
|
+
/ssn/i,
|
|
18
|
+
/credit[-_]?card/i,
|
|
19
|
+
/cvv/i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const SENSITIVE_QUERY_PARAMS = [
|
|
23
|
+
'token',
|
|
24
|
+
'auth',
|
|
25
|
+
'key',
|
|
26
|
+
'apikey',
|
|
27
|
+
'api_key',
|
|
28
|
+
'secret',
|
|
29
|
+
'password',
|
|
30
|
+
'access_token',
|
|
31
|
+
'refresh_token',
|
|
32
|
+
'code',
|
|
33
|
+
'signature',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const REDACTED_PLACEHOLDER = '[REDACTED]';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Checks if a property name matches any sensitive pattern
|
|
40
|
+
*/
|
|
41
|
+
export function isSensitiveKey(key: string): boolean {
|
|
42
|
+
if (!key) return false;
|
|
43
|
+
const cleaned = key.replace(/[-_]/g, '');
|
|
44
|
+
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key) || pattern.test(cleaned));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Redacts sensitive query parameters from a URL string
|
|
49
|
+
*/
|
|
50
|
+
export function redactUrl(rawUrl: string): string {
|
|
51
|
+
if (!rawUrl) return rawUrl;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const isRelative = !rawUrl.startsWith('http://') && !rawUrl.startsWith('https://') && !rawUrl.startsWith('ws://') && !rawUrl.startsWith('wss://');
|
|
55
|
+
const base = 'http://localhost';
|
|
56
|
+
const parsed = new URL(rawUrl, base);
|
|
57
|
+
|
|
58
|
+
let changed = false;
|
|
59
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
60
|
+
if (parsed.searchParams.has(param)) {
|
|
61
|
+
parsed.searchParams.set(param, REDACTED_PLACEHOLDER);
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Also check all query params against isSensitiveKey
|
|
67
|
+
for (const key of Array.from(parsed.searchParams.keys())) {
|
|
68
|
+
if (isSensitiveKey(key)) {
|
|
69
|
+
parsed.searchParams.set(key, REDACTED_PLACEHOLDER);
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!changed) return rawUrl;
|
|
75
|
+
|
|
76
|
+
let result = isRelative ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
|
77
|
+
// Normalize URL-encoded brackets %5BREDACTED%5D back to [REDACTED]
|
|
78
|
+
result = result.replace(/%5BREDACTED%5D/g, REDACTED_PLACEHOLDER);
|
|
79
|
+
return result;
|
|
80
|
+
} catch {
|
|
81
|
+
// If URL parsing fails, perform regex replace
|
|
82
|
+
let safe = rawUrl;
|
|
83
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
84
|
+
const reg = new RegExp(`([?&]${param}=)[^&#]+`, 'gi');
|
|
85
|
+
safe = safe.replace(reg, `$1${REDACTED_PLACEHOLDER}`);
|
|
86
|
+
}
|
|
87
|
+
return safe;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Redacts sensitive HTTP headers
|
|
93
|
+
*/
|
|
94
|
+
export function redactHeaders(headers: Record<string, string>): Record<string, string> {
|
|
95
|
+
if (!headers || typeof headers !== 'object') return {};
|
|
96
|
+
|
|
97
|
+
const safe: Record<string, string> = {};
|
|
98
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
99
|
+
if (isSensitiveKey(key)) {
|
|
100
|
+
safe[key] = REDACTED_PLACEHOLDER;
|
|
101
|
+
} else {
|
|
102
|
+
safe[key] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return safe;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Deeply redacts sensitive keys from an object or array
|
|
110
|
+
*/
|
|
111
|
+
export function redactSensitiveData<T = any>(data: T, maxDepth = 6, currentDepth = 0): T {
|
|
112
|
+
if (data === null || data === undefined) return data;
|
|
113
|
+
if (typeof data !== 'object') return data;
|
|
114
|
+
if (currentDepth > maxDepth) return '[DEPTH_EXCEEDED]' as any;
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(data)) {
|
|
117
|
+
return data.map((item) => redactSensitiveData(item, maxDepth, currentDepth + 1)) as any;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result: Record<string, any> = {};
|
|
121
|
+
for (const [key, value] of Object.entries(data as Record<string, any>)) {
|
|
122
|
+
if (typeof value === 'object' && value !== null) {
|
|
123
|
+
result[key] = redactSensitiveData(value, maxDepth, currentDepth + 1);
|
|
124
|
+
} else if (isSensitiveKey(key)) {
|
|
125
|
+
result[key] = REDACTED_PLACEHOLDER;
|
|
126
|
+
} else if (typeof value === 'string') {
|
|
127
|
+
// Check if value is a URL containing secrets
|
|
128
|
+
if (value.startsWith('http://') || value.startsWith('https://') || value.includes('?')) {
|
|
129
|
+
result[key] = redactUrl(value);
|
|
130
|
+
} else {
|
|
131
|
+
result[key] = value;
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
result[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result as T;
|
|
139
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic element selector generation and inspection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SelectorOptions {
|
|
6
|
+
maxClasses?: number;
|
|
7
|
+
maxOuterHTMLLength?: number;
|
|
8
|
+
maxInnerTextLength?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a clean, human-readable semantic selector for a DOM element.
|
|
13
|
+
* Prioritizes data-testid/data-test, id, semantic tag + specific class, or concise parent path.
|
|
14
|
+
*/
|
|
15
|
+
export function getSemanticSelector(element: HTMLElement | Element, options: SelectorOptions = {}): string {
|
|
16
|
+
if (!element || !element.tagName) return 'unknown';
|
|
17
|
+
|
|
18
|
+
const maxClasses = options.maxClasses ?? 2;
|
|
19
|
+
|
|
20
|
+
// 1. Check for standard test attributes
|
|
21
|
+
const testId =
|
|
22
|
+
element.getAttribute('data-testid') ||
|
|
23
|
+
element.getAttribute('data-test') ||
|
|
24
|
+
element.getAttribute('data-cy') ||
|
|
25
|
+
element.getAttribute('data-qa');
|
|
26
|
+
|
|
27
|
+
if (testId) {
|
|
28
|
+
return `[data-testid="${testId}"]`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Check for meaningful ID (skip dynamic UUID/numeric IDs)
|
|
32
|
+
const id = element.id;
|
|
33
|
+
if (id && !/^[0-9]+$/.test(id) && !/[0-9a-f]{8}-[0-9a-f]{4}/i.test(id)) {
|
|
34
|
+
return `#${id}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. Check for aria-label or name on buttons/inputs
|
|
38
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
39
|
+
if (ariaLabel && ariaLabel.length < 30) {
|
|
40
|
+
return `${element.tagName.toLowerCase()}[aria-label="${ariaLabel}"]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nameAttr = element.getAttribute('name');
|
|
44
|
+
if (nameAttr) {
|
|
45
|
+
return `${element.tagName.toLowerCase()}[name="${nameAttr}"]`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 4. Tag + curated classes
|
|
49
|
+
const tag = element.tagName.toLowerCase();
|
|
50
|
+
const classList = Array.from(element.classList || [])
|
|
51
|
+
.filter((c) => !c.startsWith('css-') && !c.startsWith('_') && !/^[a-z0-9]{5,}$/i.test(c)) // filter hashed utility classes
|
|
52
|
+
.slice(0, maxClasses);
|
|
53
|
+
|
|
54
|
+
if (classList.length > 0) {
|
|
55
|
+
const classStr = classList.map((c) => `.${c}`).join('');
|
|
56
|
+
return `${tag}${classStr}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 5. If generic element (div, span, p) without attributes, try parent context
|
|
60
|
+
if (element.parentElement && element.parentElement !== document.body && element.parentElement !== document.documentElement) {
|
|
61
|
+
const parentSelector = getSemanticSelector(element.parentElement, { maxClasses: 1 });
|
|
62
|
+
if (parentSelector && parentSelector !== 'unknown' && !parentSelector.includes('>')) {
|
|
63
|
+
return `${parentSelector} > ${tag}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tag;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Truncates string to specified max length with ellipsis
|
|
72
|
+
*/
|
|
73
|
+
export function truncate(str: string | undefined | null, maxLength = 200): string {
|
|
74
|
+
if (!str) return '';
|
|
75
|
+
if (str.length <= maxLength) return str;
|
|
76
|
+
return str.slice(0, maxLength) + '...';
|
|
77
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ElementSummary } from './events.js';
|
|
2
|
+
|
|
3
|
+
export type CommandType =
|
|
4
|
+
| 'reload'
|
|
5
|
+
| 'navigate'
|
|
6
|
+
| 'get_page_state'
|
|
7
|
+
| 'query_element'
|
|
8
|
+
| 'capture_element'
|
|
9
|
+
| 'check_overflow'
|
|
10
|
+
| 'get_element_rect'
|
|
11
|
+
| 'get_element_style';
|
|
12
|
+
|
|
13
|
+
export interface ClientCommand<T = any> {
|
|
14
|
+
id: string;
|
|
15
|
+
type: CommandType;
|
|
16
|
+
params?: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReloadParams {
|
|
20
|
+
force?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface NavigateParams {
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QueryElementParams {
|
|
28
|
+
selector: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CaptureElementParams {
|
|
32
|
+
selector?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface QueryElementResult {
|
|
36
|
+
exists: boolean;
|
|
37
|
+
visible?: boolean;
|
|
38
|
+
tag?: string;
|
|
39
|
+
id?: string;
|
|
40
|
+
classes?: string[];
|
|
41
|
+
boundingRect?: {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
top: number;
|
|
47
|
+
left: number;
|
|
48
|
+
bottom: number;
|
|
49
|
+
right: number;
|
|
50
|
+
};
|
|
51
|
+
innerText?: string;
|
|
52
|
+
outerHTML?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PageStateResult {
|
|
56
|
+
url: string;
|
|
57
|
+
route: string;
|
|
58
|
+
title: string;
|
|
59
|
+
readyState: string;
|
|
60
|
+
activeElement?: ElementSummary;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CaptureElementResult {
|
|
64
|
+
dataUrl?: string;
|
|
65
|
+
format?: string;
|
|
66
|
+
width?: number;
|
|
67
|
+
height?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface OverflowCheckResult {
|
|
71
|
+
selector: string;
|
|
72
|
+
overflow: boolean;
|
|
73
|
+
viewportWidth: number;
|
|
74
|
+
viewportHeight: number;
|
|
75
|
+
rect?: {
|
|
76
|
+
x: number;
|
|
77
|
+
y: number;
|
|
78
|
+
width: number;
|
|
79
|
+
height: number;
|
|
80
|
+
top: number;
|
|
81
|
+
left: number;
|
|
82
|
+
bottom: number;
|
|
83
|
+
right: number;
|
|
84
|
+
};
|
|
85
|
+
overflowRightPx: number;
|
|
86
|
+
overflowBottomPx: number;
|
|
87
|
+
parentOverflow?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ElementStyleResult {
|
|
91
|
+
selector: string;
|
|
92
|
+
styles: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CommandResponse<T = any> {
|
|
96
|
+
id: string;
|
|
97
|
+
ok: boolean;
|
|
98
|
+
result?: T;
|
|
99
|
+
error?: string;
|
|
100
|
+
reason?: string;
|
|
101
|
+
}
|