@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/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
+ }