@sphoro/bugcatch-web-sdk 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/dist/bugcatch.es.js +4393 -0
- package/dist/bugcatch.es.js.map +1 -0
- package/dist/bugcatch.umd.js +952 -0
- package/dist/bugcatch.umd.js.map +1 -0
- package/package.json +57 -0
- package/src/audio.js +191 -0
- package/src/capture.js +300 -0
- package/src/collectors.js +213 -0
- package/src/incoming-call.js +324 -0
- package/src/index.d.ts +684 -0
- package/src/index.js +1217 -0
- package/src/live-call.js +870 -0
- package/src/redact.js +177 -0
- package/src/session.js +189 -0
- package/src/updates.js +1062 -0
- package/src/voice.js +372 -0
- package/src/widget.js +2481 -0
package/src/redact.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Strips credentials out of captured URLs and error text, at the moment of
|
|
2
|
+
// capture — before anything is buffered, and long before anything is sent.
|
|
3
|
+
//
|
|
4
|
+
// Request and response bodies are never read, so the URL is the whole attack
|
|
5
|
+
// surface, and in practice it is a wide one: `?api_key=`, S3 presigned
|
|
6
|
+
// signatures, OAuth fragments, JWTs sitting in a path segment. A presigned URL
|
|
7
|
+
// is not a hint that a credential exists, it *is* the credential, and captured
|
|
8
|
+
// network logs are shown on public share links and in the reporter portal.
|
|
9
|
+
//
|
|
10
|
+
// The goal is a redaction that leaves the log useful. `?token=[REDACTED]` still
|
|
11
|
+
// tells a triager which endpoint was called with what shape of argument;
|
|
12
|
+
// blanking the whole URL would make the Network tab worthless and push people
|
|
13
|
+
// to turn capture off, which is the worse outcome.
|
|
14
|
+
//
|
|
15
|
+
// Twin implementations live in bugcatch-extension/src/collector.js (no build
|
|
16
|
+
// step there, so it cannot import) and bugcatch-api/src/settings/redaction.util.ts
|
|
17
|
+
// (defence in depth for older SDKs). tests/unit/redact-url.test.mjs runs one
|
|
18
|
+
// corpus through all three and fails if they ever disagree.
|
|
19
|
+
|
|
20
|
+
export const PLACEHOLDER = '[REDACTED]';
|
|
21
|
+
|
|
22
|
+
// Keys whose value is a credential whatever it looks like. Matched after
|
|
23
|
+
// normalising, so `X-Amz-Security-Token` arrives here as `xamzsecuritytoken`.
|
|
24
|
+
const ALWAYS = new Set([
|
|
25
|
+
'auth', 'bearer', 'session', 'sid', 'phpsessid', 'otp',
|
|
26
|
+
'pass', 'passwd', 'pwd', 'sig', 'codeverifier',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Substrings that make a key sensitive wherever they appear, so app-specific
|
|
30
|
+
// names like `x_auth_token` or `myAppApiKey` are covered without enumeration.
|
|
31
|
+
// Deliberately excludes bare `auth` (matches `author`) and bare `key`
|
|
32
|
+
// (matches `keyword`, `monkey`) — both are handled more precisely elsewhere.
|
|
33
|
+
const MARKERS = [
|
|
34
|
+
'token', 'secret', 'password', 'passwd', 'apikey', 'signature',
|
|
35
|
+
'credential', 'sessionid', 'privatekey', 'accesskey', 'authorization',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Keys that are only sometimes a credential. `?key=AIza…` is a Google API key;
|
|
39
|
+
// `?key=name` is a sort order. `?code=` is an OAuth code or an error string.
|
|
40
|
+
// These redact only when the value itself looks like a secret, because
|
|
41
|
+
// over-redacting the common case costs more triage value than it buys.
|
|
42
|
+
const AMBIGUOUS = new Set(['key', 'code', 'state', 'nonce', 'salt', 'hash']);
|
|
43
|
+
|
|
44
|
+
// Recognisable credential formats, matched anywhere — query, path segment or
|
|
45
|
+
// free text — so a secret still gets caught when it is not behind a named
|
|
46
|
+
// parameter at all.
|
|
47
|
+
const SHAPES = [
|
|
48
|
+
/eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, // JWT
|
|
49
|
+
/(?:AKIA|ASIA|AROA|AIDA|AGPA|ANPA|ANVA|APKA)[0-9A-Z]{16}/g, // AWS key id
|
|
50
|
+
/AIza[\w-]{35}/g, // Google API key
|
|
51
|
+
/gh[pousr]_[A-Za-z0-9]{36,}/g, // GitHub token
|
|
52
|
+
/github_pat_\w{22,}/g, // GitHub fine-grained PAT
|
|
53
|
+
/[sprk]k_(?:live|test)_[0-9A-Za-z]{10,}/g, // Stripe
|
|
54
|
+
/xox[abposr]-[A-Za-z0-9-]{10,}/g, // Slack
|
|
55
|
+
/sk-ant-[\w-]{20,}/g, // Anthropic
|
|
56
|
+
/sk-[\w-]{20,}/g, // OpenAI
|
|
57
|
+
/glpat-[\w-]{20,}/g, // GitLab
|
|
58
|
+
/shp(?:at|ss|ca|pa)_[A-Za-z0-9]{32}/g, // Shopify
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// `Authorization: Bearer …` as it appears in thrown error text. The class is
|
|
62
|
+
// written without a letter range because the `i` flag already covers case.
|
|
63
|
+
const BEARER = /\bBearer\s+[\w.~+/=-]{8,}/gi;
|
|
64
|
+
|
|
65
|
+
// Credentials in the authority, which fetch() honours: https://user:pass@host.
|
|
66
|
+
const USERINFO = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^/?#@]+)@/;
|
|
67
|
+
|
|
68
|
+
function safeDecode(value) {
|
|
69
|
+
try {
|
|
70
|
+
return decodeURIComponent(value.replace(/\+/g, ' '));
|
|
71
|
+
} catch {
|
|
72
|
+
return value; // a malformed escape is not a reason to give up on the rest
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeKey(rawKey) {
|
|
77
|
+
return safeDecode(rawKey).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether a value carried by an ambiguous key is credential-shaped. */
|
|
81
|
+
function looksSecret(value) {
|
|
82
|
+
if (value.length >= 20) return true;
|
|
83
|
+
return SHAPES.some((re) => {
|
|
84
|
+
re.lastIndex = 0;
|
|
85
|
+
return re.test(value);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isSensitiveKey(rawKey, rawValue) {
|
|
90
|
+
const key = normalizeKey(rawKey);
|
|
91
|
+
if (!key) return false;
|
|
92
|
+
if (ALWAYS.has(key)) return true;
|
|
93
|
+
for (const marker of MARKERS) if (key.includes(marker)) return true;
|
|
94
|
+
if (AMBIGUOUS.has(key)) return looksSecret(safeDecode(rawValue));
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Redact the values of sensitive keys in an `a=1&b=2` string. */
|
|
99
|
+
function redactPairs(query) {
|
|
100
|
+
return query
|
|
101
|
+
.split('&')
|
|
102
|
+
.map((pair) => {
|
|
103
|
+
const eq = pair.indexOf('=');
|
|
104
|
+
if (eq === -1) return pair;
|
|
105
|
+
const key = pair.slice(0, eq);
|
|
106
|
+
const value = pair.slice(eq + 1);
|
|
107
|
+
if (!value) return pair;
|
|
108
|
+
return isSensitiveKey(key, value) ? `${key}=${PLACEHOLDER}` : pair;
|
|
109
|
+
})
|
|
110
|
+
.join('&');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Replace every recognisable credential format in a string. */
|
|
114
|
+
function redactShapes(text) {
|
|
115
|
+
let out = text;
|
|
116
|
+
for (const re of SHAPES) {
|
|
117
|
+
re.lastIndex = 0;
|
|
118
|
+
out = out.replace(re, PLACEHOLDER);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scrub a single URL. Structure is preserved — scheme, host, path and the
|
|
125
|
+
* names of every parameter survive, only credential values are replaced.
|
|
126
|
+
* Works on relative URLs too, which is why this parses by hand rather than
|
|
127
|
+
* going through `new URL()`: that would also re-encode and reorder, so the
|
|
128
|
+
* logged URL would stop matching what the app actually requested.
|
|
129
|
+
*/
|
|
130
|
+
export function redactUrl(raw) {
|
|
131
|
+
if (raw === null || raw === undefined) return raw;
|
|
132
|
+
let url = String(raw);
|
|
133
|
+
if (!url) return url;
|
|
134
|
+
|
|
135
|
+
url = url.replace(USERINFO, `$1${PLACEHOLDER}@`);
|
|
136
|
+
|
|
137
|
+
const hashAt = url.indexOf('#');
|
|
138
|
+
let head = hashAt === -1 ? url : url.slice(0, hashAt);
|
|
139
|
+
let hash = hashAt === -1 ? '' : url.slice(hashAt);
|
|
140
|
+
|
|
141
|
+
const queryAt = head.indexOf('?');
|
|
142
|
+
if (queryAt !== -1) {
|
|
143
|
+
head = head.slice(0, queryAt + 1) + redactPairs(head.slice(queryAt + 1));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (hash.length > 1) {
|
|
147
|
+
const body = hash.slice(1);
|
|
148
|
+
const innerQuery = body.indexOf('?');
|
|
149
|
+
if (innerQuery !== -1) {
|
|
150
|
+
// Hash-routed app: #/reset?token=…
|
|
151
|
+
hash = `#${body.slice(0, innerQuery + 1)}${redactPairs(body.slice(innerQuery + 1))}`;
|
|
152
|
+
} else if (body.includes('=')) {
|
|
153
|
+
// OAuth implicit flow drops the token here, where it never reaches a
|
|
154
|
+
// server but is very much still a credential.
|
|
155
|
+
hash = `#${redactPairs(body)}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return redactShapes(head + hash);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Scrub free text — a thrown error message, a console line. Any URL inside it
|
|
164
|
+
* is scrubbed as a URL, then bare credentials and `Bearer …` headers go too.
|
|
165
|
+
*/
|
|
166
|
+
export function redactText(raw) {
|
|
167
|
+
if (raw === null || raw === undefined) return raw;
|
|
168
|
+
let text = String(raw);
|
|
169
|
+
if (!text) return text;
|
|
170
|
+
|
|
171
|
+
// The scheme is length-bounded so a long run of scheme-legal characters that
|
|
172
|
+
// never reaches `://` cannot backtrack quadratically. The same source runs
|
|
173
|
+
// server-side over attacker-influenced text, where that would be a DoS.
|
|
174
|
+
text = text.replace(/[A-Za-z][A-Za-z0-9+.-]{0,30}:\/\/[^\s"'<>)\]]+/g, (m) => redactUrl(m));
|
|
175
|
+
text = text.replace(BEARER, `Bearer ${PLACEHOLDER}`);
|
|
176
|
+
return redactShapes(text);
|
|
177
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Signing in to a project's widget.
|
|
2
|
+
//
|
|
3
|
+
// Two different things call themselves "who is reporting this" in this SDK, and
|
|
4
|
+
// they are not the same claim:
|
|
5
|
+
//
|
|
6
|
+
// identify() — the host application telling us about its own signed-in user.
|
|
7
|
+
// Free, silent, and worth exactly as much as the application
|
|
8
|
+
// saying it. Nothing here challenges it.
|
|
9
|
+
//
|
|
10
|
+
// signIn() — the person in front of the browser answering a code we mailed
|
|
11
|
+
// to the address they typed. Costs them a round trip to their
|
|
12
|
+
// inbox, and is the only one that survives a project deciding
|
|
13
|
+
// it wants a real address on every report.
|
|
14
|
+
//
|
|
15
|
+
// Which of the two a project asks for is the project's own setting, read from
|
|
16
|
+
// the API rather than configured here — see loginState(). A widget that guessed
|
|
17
|
+
// would either hide a mandatory sign-in or put one in front of a project that
|
|
18
|
+
// never asked for it.
|
|
19
|
+
//
|
|
20
|
+
// The session is stored per project, because that is the boundary the server
|
|
21
|
+
// draws too: one live session per address per project. A browser on a page that
|
|
22
|
+
// mounts two projects' widgets is two independent sign-ins, and signing out of
|
|
23
|
+
// one leaves the other alone.
|
|
24
|
+
|
|
25
|
+
const KEY_PREFIX = 'bugcatch.session.';
|
|
26
|
+
|
|
27
|
+
const storageKey = (projectId) => `${KEY_PREFIX}${projectId}`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The same sessions, held for the life of the page.
|
|
31
|
+
*
|
|
32
|
+
* Not a cache — a fallback. localStorage is unavailable outright in some
|
|
33
|
+
* privacy modes and on some embedded webviews, and without this a project that
|
|
34
|
+
* requires sign-in would be unusable there: the code would be accepted and the
|
|
35
|
+
* session immediately forgotten, so the report that follows is refused. Held
|
|
36
|
+
* here, signing in still works for as long as the tab is open; all that is lost
|
|
37
|
+
* is surviving a reload, which is the part storage was buying.
|
|
38
|
+
*/
|
|
39
|
+
const inMemory = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The session for one project, or null.
|
|
43
|
+
*
|
|
44
|
+
* Storage can throw outright in private modes, and can hold something another
|
|
45
|
+
* version of this SDK wrote — both come back as "not signed in" rather than an
|
|
46
|
+
* exception in the host application's page.
|
|
47
|
+
*/
|
|
48
|
+
export function readSession(projectId) {
|
|
49
|
+
if (!projectId) return null;
|
|
50
|
+
try {
|
|
51
|
+
const raw = window.localStorage?.getItem(storageKey(projectId));
|
|
52
|
+
if (raw) {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
if (parsed?.token) return parsed;
|
|
55
|
+
}
|
|
56
|
+
} catch { /* storage unavailable, or holding something we did not write */ }
|
|
57
|
+
return inMemory.get(projectId) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function writeSession(projectId, session) {
|
|
61
|
+
if (!projectId || !session?.token) return;
|
|
62
|
+
const held = {
|
|
63
|
+
token: session.token,
|
|
64
|
+
email: session.email || null,
|
|
65
|
+
name: session.name || null,
|
|
66
|
+
};
|
|
67
|
+
inMemory.set(projectId, held);
|
|
68
|
+
try {
|
|
69
|
+
window.localStorage?.setItem(storageKey(projectId), JSON.stringify(held));
|
|
70
|
+
} catch { /* the session just won't survive a reload */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearSession(projectId) {
|
|
74
|
+
if (!projectId) return;
|
|
75
|
+
inMemory.delete(projectId);
|
|
76
|
+
try {
|
|
77
|
+
window.localStorage?.removeItem(storageKey(projectId));
|
|
78
|
+
} catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The headers every call here sends: the publishable key, plus who we are. */
|
|
82
|
+
function headers({ apiKey, token }) {
|
|
83
|
+
return {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'X-BugCatch-Key': apiKey,
|
|
86
|
+
// Its own header rather than Authorization, which on these routes already
|
|
87
|
+
// carries the API key. The two answer different questions and a request
|
|
88
|
+
// carries both.
|
|
89
|
+
...(token ? { 'X-BugCatch-Session': token } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readError(res) {
|
|
94
|
+
try {
|
|
95
|
+
const body = await res.json();
|
|
96
|
+
const message = Array.isArray(body.message) ? body.message.join(', ') : body.message;
|
|
97
|
+
if (message) return message;
|
|
98
|
+
} catch { /* non-JSON error */ }
|
|
99
|
+
return `Request failed (HTTP ${res.status})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What this project asks of the reporter, and who this browser is signed in as.
|
|
104
|
+
*
|
|
105
|
+
* { mode: 'off' | 'optional' | 'required',
|
|
106
|
+
* signedIn: { email, name } | null,
|
|
107
|
+
* signedOutReason: string | null }
|
|
108
|
+
*
|
|
109
|
+
* Asked each time the panel opens rather than cached for the page: the session
|
|
110
|
+
* it reports on can end elsewhere — the same address signing in on a phone ends
|
|
111
|
+
* the one on this laptop — and a widget holding a cached "signed in as you"
|
|
112
|
+
* would only find out at the moment the report failed.
|
|
113
|
+
*
|
|
114
|
+
* Never throws. A deployment that has not been updated answers 404 here, and an
|
|
115
|
+
* SDK that treated that as fatal would take the whole widget down over a
|
|
116
|
+
* feature the project had not switched on; the answer is "off", which is what
|
|
117
|
+
* every project looked like before this existed.
|
|
118
|
+
*/
|
|
119
|
+
export async function loginState({ apiBase, apiKey, projectId, token }) {
|
|
120
|
+
const off = { mode: 'off', signedIn: null, signedOutReason: null };
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(
|
|
123
|
+
`${apiBase}/ingest/login?projectId=${encodeURIComponent(projectId)}`,
|
|
124
|
+
{ headers: headers({ apiKey, token }) },
|
|
125
|
+
);
|
|
126
|
+
if (!res.ok) return off;
|
|
127
|
+
const body = await res.json();
|
|
128
|
+
return {
|
|
129
|
+
mode: body.mode === 'optional' || body.mode === 'required' ? body.mode : 'off',
|
|
130
|
+
signedIn: body.signedIn || null,
|
|
131
|
+
signedOutReason: body.signedOutReason || null,
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return off;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Step one: ask for a code. Throws with the server's sentence on refusal. */
|
|
139
|
+
export async function startSignIn({ apiBase, apiKey, projectId, email, name }) {
|
|
140
|
+
const res = await fetch(`${apiBase}/ingest/login/start`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: headers({ apiKey }),
|
|
143
|
+
body: JSON.stringify({ projectId, email, ...(name ? { name } : {}) }),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) throw new Error(await readError(res));
|
|
146
|
+
return res.json();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Step two: spend the code.
|
|
151
|
+
*
|
|
152
|
+
* Stores the session on success, which is what makes it survive a reload and
|
|
153
|
+
* what every other call here reads. Signing in ends this address's session on
|
|
154
|
+
* this project everywhere else — that is the server's rule, not something this
|
|
155
|
+
* browser can opt out of.
|
|
156
|
+
*/
|
|
157
|
+
export async function confirmSignIn({ apiBase, apiKey, projectId, email, code }) {
|
|
158
|
+
const res = await fetch(`${apiBase}/ingest/login`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: headers({ apiKey }),
|
|
161
|
+
body: JSON.stringify({ projectId, email, code }),
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) throw new Error(await readError(res));
|
|
164
|
+
|
|
165
|
+
const body = await res.json();
|
|
166
|
+
const session = { token: body.sessionToken, email: body.email, name: body.name };
|
|
167
|
+
writeSession(projectId, session);
|
|
168
|
+
return session;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sign out, here and on the server.
|
|
173
|
+
*
|
|
174
|
+
* Local storage is cleared whatever the network says. Somebody pressing sign
|
|
175
|
+
* out on a shared machine has to end up signed out on it, and a fetch that
|
|
176
|
+
* failed is not a reason to leave their address in the next person's widget.
|
|
177
|
+
*/
|
|
178
|
+
export async function endSignIn({ apiBase, apiKey, projectId, token }) {
|
|
179
|
+
clearSession(projectId);
|
|
180
|
+
if (!token) return { signedOut: true };
|
|
181
|
+
try {
|
|
182
|
+
await fetch(`${apiBase}/ingest/logout`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: headers({ apiKey, token }),
|
|
185
|
+
body: JSON.stringify({ projectId }),
|
|
186
|
+
});
|
|
187
|
+
} catch { /* the token is already unusable from this browser */ }
|
|
188
|
+
return { signedOut: true };
|
|
189
|
+
}
|