@hanzo/event 0.3.1 → 0.3.2
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 +61 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/index.cjs +399 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -3
- package/dist/index.d.ts +65 -3
- package/dist/index.mjs +393 -19
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +392 -18
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +392 -18
- package/dist/react.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +258 -5
- package/src/core.ts +225 -37
- package/src/index.ts +8 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +112 -0
- package/src/sentry.test.ts +260 -0
- package/src/sentry.ts +279 -0
- package/src/types.ts +117 -15
- package/src/version.ts +4 -0
- package/dist/core-CrbiAQhN.d.cts +0 -186
- package/dist/core-CrbiAQhN.d.ts +0 -186
package/src/core.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
// The framework-agnostic event client.
|
|
2
|
-
//
|
|
1
|
+
// The framework-agnostic event client. ONE API surface over TWO orthogonal
|
|
2
|
+
// planes, sharing one session and one identity:
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// 1. EVENT STREAM — buffered pageview/event/identify/group, flushed as ONE
|
|
5
|
+
// batch to the Hanzo Cloud front door:
|
|
6
|
+
// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
|
|
7
|
+
// Cloud resolves the tenant server-side (validated session, or the signed
|
|
8
|
+
// publishable key) and stamps it; the client NEVER sends the org.
|
|
5
9
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// 2. ERROR PLANE — every captured exception is ALSO framed as a real Sentry
|
|
11
|
+
// envelope and POSTed to the error host named by the DSN:
|
|
12
|
+
// POST {dsn.origin}/v1/sentry/{projectId}/envelope/?sentry_key=…
|
|
13
|
+
// This is what reaches sentry.hanzo.ai (issues, grouping, stack frames).
|
|
14
|
+
//
|
|
15
|
+
// These are NOT the same pipe and one does NOT feed the other. The event stream
|
|
16
|
+
// stores a `type:'error'` row in the cloud event warehouse (readable via
|
|
17
|
+
// GET /v1/errors) — that is product signal, not error tracking. There is no
|
|
18
|
+
// server-side fan-out from /v1/event into Sentry; without the envelope below,
|
|
19
|
+
// nothing ever reaches sentry.hanzo.ai. An earlier revision of this file claimed
|
|
20
|
+
// the one door was "lensed server-side into … error tracking (sentry)". It was
|
|
21
|
+
// wrong, and it silently cost the fleet all of its error telemetry.
|
|
22
|
+
//
|
|
23
|
+
// The error plane is inert (fail-safe) when no DSN is configured: nothing is
|
|
24
|
+
// sent, nothing throws, and the event stream is unaffected.
|
|
11
25
|
//
|
|
12
26
|
// Auth is orthogonal — the SAME body to the SAME door, differing only in how the
|
|
13
27
|
// caller proves its tenant:
|
|
@@ -19,10 +33,10 @@
|
|
|
19
33
|
// safe to ship in a bundle; the door HMAC-verifies it to an org server-side.
|
|
20
34
|
//
|
|
21
35
|
// The wire is the canonical `Event` (== the cloud CaptureEvent): its `type` field
|
|
22
|
-
// is what Cloud folds to event_type='error',
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
36
|
+
// is what Cloud folds to event_type='error', which is how the event WAREHOUSE
|
|
37
|
+
// classifies the row (GET /v1/errors). That is the extent of it — the fold does
|
|
38
|
+
// not forward anything to Sentry. The error dashboard is fed only by the envelope
|
|
39
|
+
// in plane 2 above, and only when a DSN is set.
|
|
26
40
|
|
|
27
41
|
import {
|
|
28
42
|
parseAttribution,
|
|
@@ -30,6 +44,7 @@ import {
|
|
|
30
44
|
deriveChannel,
|
|
31
45
|
} from './attribution'
|
|
32
46
|
import { PAGEVIEW } from './events'
|
|
47
|
+
import { buildEnvelope, buildSentryEvent, parseDsn, type ErrorIdentity } from './sentry'
|
|
33
48
|
import {
|
|
34
49
|
anonId,
|
|
35
50
|
sessionId,
|
|
@@ -41,17 +56,46 @@ import {
|
|
|
41
56
|
import type {
|
|
42
57
|
AnalyticsConfig,
|
|
43
58
|
Attribution,
|
|
59
|
+
CaptureErrorOptions,
|
|
44
60
|
Cohort,
|
|
61
|
+
Dsn,
|
|
45
62
|
EventKind,
|
|
46
63
|
Exception,
|
|
47
64
|
Transport,
|
|
48
65
|
WireEvent,
|
|
49
66
|
} from './types'
|
|
67
|
+
import { VERSION } from './version'
|
|
50
68
|
|
|
51
|
-
export
|
|
69
|
+
export { VERSION }
|
|
52
70
|
|
|
53
71
|
const EVENT_PATH = '/v1/event' // the ONE canonical ingestion front door
|
|
54
72
|
const DEFAULT_HOST = 'https://api.hanzo.ai' // the one edge; cookie apps pass host:''
|
|
73
|
+
const ENVELOPE_CONTENT_TYPE = 'application/x-sentry-envelope'
|
|
74
|
+
|
|
75
|
+
/** readEnvDsn resolves a DSN from the public env when config omits one, so an app
|
|
76
|
+
* gets the error plane by setting ONE build-time variable and nothing else.
|
|
77
|
+
* Next/Vite inline these at build; the access is guarded so it is safe in a bare
|
|
78
|
+
* browser and during SSR/prerender where `process` may not exist. */
|
|
79
|
+
function readEnvDsn(): string | undefined {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
82
|
+
return process.env.NEXT_PUBLIC_HANZO_EVENT_DSN || process.env.HANZO_EVENT_DSN || undefined
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* no process — browser without inlined env */
|
|
86
|
+
}
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** readEnv reads an inlined build-time variable, guarded like readEnvDsn. */
|
|
91
|
+
function readEnv(name: string): string | undefined {
|
|
92
|
+
try {
|
|
93
|
+
if (typeof process !== 'undefined' && process.env) return process.env[name] || undefined
|
|
94
|
+
} catch {
|
|
95
|
+
/* no process */
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
55
99
|
|
|
56
100
|
/** appendQuery adds a single query param to a URL string — used to carry a
|
|
57
101
|
* publishable key on a headerless sendBeacon (?ingest_key=…). */
|
|
@@ -80,23 +124,63 @@ function normalizeError(err: unknown): Exception {
|
|
|
80
124
|
|
|
81
125
|
const isBrowser = () => typeof window !== 'undefined'
|
|
82
126
|
|
|
127
|
+
/** serializeBatch stringifies a batch, salvaging what it can. `properties` is
|
|
128
|
+
* arbitrary caller data — a DOM node, a React synthetic event, an axios error are
|
|
129
|
+
* all circular, and a getter on one can throw — so a whole batch must never be
|
|
130
|
+
* lost to a single poisoned event. Falls back to per-event serialization, then to
|
|
131
|
+
* keeping the event and dropping its properties. Returns null only when nothing
|
|
132
|
+
* at all survives. */
|
|
133
|
+
function serializeBatch(batch: WireEvent[]): string | null {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.stringify({ batch })
|
|
136
|
+
} catch {
|
|
137
|
+
/* one bad event — salvage the rest below */
|
|
138
|
+
}
|
|
139
|
+
const parts: string[] = []
|
|
140
|
+
for (const e of batch) {
|
|
141
|
+
try {
|
|
142
|
+
parts.push(JSON.stringify(e))
|
|
143
|
+
} catch {
|
|
144
|
+
try {
|
|
145
|
+
// Keep the event (identity, session, type all matter); drop the payload
|
|
146
|
+
// that could not be serialized, and say so rather than lying by omission.
|
|
147
|
+
parts.push(JSON.stringify({ ...e, properties: { $unserializable: true } }))
|
|
148
|
+
} catch {
|
|
149
|
+
/* unsalvageable — drop this ONE event, never the batch */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return parts.length > 0 ? '{"batch":[' + parts.join(',') + ']}' : null
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
/** DefaultTransport: fetch(keepalive) for authenticated/normal sends;
|
|
84
157
|
* navigator.sendBeacon for headerless page-unload beacons. A bearer (a JWT or a
|
|
85
158
|
* publishable pk_ key) rides Authorization on fetch; on a beacon — which cannot
|
|
86
159
|
* set headers — a publishable key rides the ?ingest_key query instead. */
|
|
87
160
|
class DefaultTransport implements Transport {
|
|
88
|
-
send(
|
|
161
|
+
send(
|
|
162
|
+
url: string,
|
|
163
|
+
body: string,
|
|
164
|
+
opts: {
|
|
165
|
+
beacon: boolean
|
|
166
|
+
token?: string
|
|
167
|
+
ingestKey?: string
|
|
168
|
+
contentType?: string
|
|
169
|
+
debug?: boolean
|
|
170
|
+
},
|
|
171
|
+
): void {
|
|
172
|
+
const contentType = opts.contentType ?? 'application/json'
|
|
89
173
|
if (opts.beacon && isBrowser() && typeof navigator.sendBeacon === 'function') {
|
|
90
174
|
const beaconUrl = opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url
|
|
91
175
|
try {
|
|
92
|
-
navigator.sendBeacon(beaconUrl, new Blob([body], { type:
|
|
176
|
+
navigator.sendBeacon(beaconUrl, new Blob([body], { type: contentType }))
|
|
93
177
|
return
|
|
94
178
|
} catch {
|
|
95
179
|
/* fall through to fetch */
|
|
96
180
|
}
|
|
97
181
|
}
|
|
98
182
|
if (typeof fetch !== 'function') return
|
|
99
|
-
const headers: Record<string, string> = { 'Content-Type':
|
|
183
|
+
const headers: Record<string, string> = { 'Content-Type': contentType }
|
|
100
184
|
const bearer = opts.ingestKey ?? opts.token
|
|
101
185
|
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
102
186
|
void fetch(url, {
|
|
@@ -105,9 +189,19 @@ class DefaultTransport implements Transport {
|
|
|
105
189
|
body,
|
|
106
190
|
keepalive: true,
|
|
107
191
|
credentials: 'include',
|
|
108
|
-
}).catch(() => {
|
|
109
|
-
/* telemetry loss is acceptable; never throw into the app */
|
|
110
192
|
})
|
|
193
|
+
.then((res) => {
|
|
194
|
+
// Telemetry loss never throws into the app — but silence is how this
|
|
195
|
+
// client lost the fleet's errors in the first place. Under `debug`, say
|
|
196
|
+
// so. A rejected ingest (the CORS allowlist 403s non-production origins,
|
|
197
|
+
// so local dev NEVER reports) is otherwise indistinguishable from success.
|
|
198
|
+
if (!res.ok && opts.debug) {
|
|
199
|
+
console.warn('[event] ingest rejected', res.status, url.split('?')[0])
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.catch((e: unknown) => {
|
|
203
|
+
if (opts.debug) console.warn('[event] ingest failed', url.split('?')[0], e)
|
|
204
|
+
})
|
|
111
205
|
}
|
|
112
206
|
}
|
|
113
207
|
|
|
@@ -123,6 +217,10 @@ export class Analytics {
|
|
|
123
217
|
private attribution: Attribution = { utm: {} }
|
|
124
218
|
private cohort: Cohort = {}
|
|
125
219
|
private started = false
|
|
220
|
+
/** Parsed error-plane DSN, or null when the plane is inert. */
|
|
221
|
+
private dsn: Dsn | null
|
|
222
|
+
/** Guards against an error thrown *inside* the error path re-entering it. */
|
|
223
|
+
private reentrant = false
|
|
126
224
|
|
|
127
225
|
constructor(config: AnalyticsConfig) {
|
|
128
226
|
this.cfg = {
|
|
@@ -134,6 +232,23 @@ export class Analytics {
|
|
|
134
232
|
...config,
|
|
135
233
|
}
|
|
136
234
|
this.transport = config.transport ?? new DefaultTransport()
|
|
235
|
+
// Error plane: explicit DSN wins, else the inlined build-time env. Malformed
|
|
236
|
+
// or absent => null => inert, never throwing into the host app.
|
|
237
|
+
this.dsn = parseDsn(config.dsn ?? readEnvDsn())
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** errorPlaneEnabled reports whether captured exceptions can actually reach the
|
|
241
|
+
* error host. False means a DSN was never configured — the documented
|
|
242
|
+
* fail-safe. Exposed so an app (or a test) can assert its wiring instead of
|
|
243
|
+
* discovering months later that nothing was ever reported. */
|
|
244
|
+
get errorPlaneEnabled(): boolean {
|
|
245
|
+
return this.dsn !== null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** errorIngestUrl is the fully-derived envelope endpoint, or undefined when the
|
|
249
|
+
* plane is inert. Diagnostics only. */
|
|
250
|
+
get errorIngestUrl(): string | undefined {
|
|
251
|
+
return this.dsn?.ingestUrl
|
|
137
252
|
}
|
|
138
253
|
|
|
139
254
|
/** init is idempotent and browser-only for its side effects: capture first-touch
|
|
@@ -161,8 +276,9 @@ export class Analytics {
|
|
|
161
276
|
window.addEventListener('pagehide', () => this.flush(true))
|
|
162
277
|
|
|
163
278
|
// Auto error capture — the drop-in @sentry replacement. Unhandled errors and
|
|
164
|
-
// rejected promises
|
|
165
|
-
//
|
|
279
|
+
// rejected promises are reported on BOTH planes: a Sentry envelope to the DSN
|
|
280
|
+
// host (what reaches the error dashboard — requires a DSN) and a type:'error'
|
|
281
|
+
// event on the stream (product signal in the warehouse).
|
|
166
282
|
if (this.cfg.captureErrors) {
|
|
167
283
|
window.addEventListener('error', (e: ErrorEvent) => {
|
|
168
284
|
this.captureError(e.error ?? e.message, { handled: false })
|
|
@@ -205,21 +321,49 @@ export class Analytics {
|
|
|
205
321
|
/** track is an alias of capture (Segment familiarity). */
|
|
206
322
|
track = this.capture.bind(this)
|
|
207
323
|
|
|
208
|
-
/** captureError
|
|
209
|
-
* error
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
324
|
+
/** captureError reports a caught error, an unhandled rejection, a React render
|
|
325
|
+
* error, or a manual report to BOTH planes, from one call:
|
|
326
|
+
*
|
|
327
|
+
* - the ERROR PLANE — a real Sentry envelope to the DSN host. This is the one
|
|
328
|
+
* that produces an issue in sentry.hanzo.ai (grouping, stack frames, AST).
|
|
329
|
+
* Inert when no DSN is configured.
|
|
330
|
+
* - the EVENT STREAM — a `type:'error'` row in the cloud event warehouse, so
|
|
331
|
+
* an error stays correlated with the session's pageviews for product
|
|
332
|
+
* analysis (readable via GET /v1/errors).
|
|
333
|
+
*
|
|
334
|
+
* Both carry the SAME session and subject id, so an error and the pageview
|
|
335
|
+
* before it join up. Never throws back into the app; errors are higher-signal
|
|
336
|
+
* than pageviews, so both planes flush promptly (a crash may unload the page
|
|
337
|
+
* moments later). */
|
|
338
|
+
captureError(err: unknown, context?: CaptureErrorOptions): void {
|
|
339
|
+
// A failure inside the error path must not recurse through the global handlers.
|
|
340
|
+
if (this.reentrant) return
|
|
341
|
+
this.reentrant = true
|
|
342
|
+
try {
|
|
343
|
+
// ERROR PLANE FIRST, in its own try. The planes are independent, so neither
|
|
344
|
+
// may be able to starve the other: `properties` is arbitrary caller data
|
|
345
|
+
// (a DOM node, a React synthetic event, an axios error — all circular and
|
|
346
|
+
// all common), and serializing it on the event stream can throw. When the
|
|
347
|
+
// stream ran first, that throw escaped to the outer catch and the crash
|
|
348
|
+
// report was never sent — silently losing exactly the signal this client
|
|
349
|
+
// exists to deliver. Order and isolation are the fix.
|
|
350
|
+
try {
|
|
351
|
+
this.sendError(err, context)
|
|
352
|
+
} catch {
|
|
353
|
+
/* the error plane must never take the event stream down with it */
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const ex = normalizeError(err)
|
|
358
|
+
ex.handled = context?.handled ?? true
|
|
359
|
+
this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
|
|
360
|
+
this.flush()
|
|
361
|
+
} catch {
|
|
362
|
+
/* nor the reverse */
|
|
363
|
+
}
|
|
364
|
+
} finally {
|
|
365
|
+
this.reentrant = false
|
|
366
|
+
}
|
|
223
367
|
}
|
|
224
368
|
|
|
225
369
|
/** captureException — @sentry-familiar alias of captureError. */
|
|
@@ -254,13 +398,57 @@ export class Analytics {
|
|
|
254
398
|
// Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
|
|
255
399
|
// Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
|
|
256
400
|
const useBeacon = beacon && !token
|
|
257
|
-
const body =
|
|
401
|
+
const body = serializeBatch(batch)
|
|
402
|
+
if (body === null) {
|
|
403
|
+
if (this.cfg.debug) console.debug('[event] flush → dropped, batch unserializable')
|
|
404
|
+
return
|
|
405
|
+
}
|
|
258
406
|
if (this.cfg.debug) console.debug('[event] flush →', EVENT_PATH, batch.length)
|
|
259
|
-
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
407
|
+
this.transport.send(this.cfg.host + EVENT_PATH, body, {
|
|
408
|
+
beacon: useBeacon,
|
|
409
|
+
token,
|
|
410
|
+
ingestKey: key,
|
|
411
|
+
debug: this.cfg.debug,
|
|
412
|
+
})
|
|
260
413
|
}
|
|
261
414
|
|
|
262
415
|
// ── internals ────────────────────────────────────────────────────────────
|
|
263
416
|
|
|
417
|
+
/** sendError frames one exception as a Sentry envelope and posts it to the DSN's
|
|
418
|
+
* ingest URL. The DSN's own key rides ?sentry_key= (the credential channel the
|
|
419
|
+
* server trusts, and the only one a headerless beacon can carry), so NO bearer
|
|
420
|
+
* or publishable key is attached here — the two planes authenticate
|
|
421
|
+
* independently. Errors are sent one envelope per event, immediately: batching
|
|
422
|
+
* a crash report is how you lose it. */
|
|
423
|
+
private sendError(err: unknown, options?: CaptureErrorOptions): void {
|
|
424
|
+
if (!this.cfg.enabled || !this.dsn) return
|
|
425
|
+
const event = buildSentryEvent({
|
|
426
|
+
error: err,
|
|
427
|
+
options,
|
|
428
|
+
identity: this.errorIdentity(),
|
|
429
|
+
capturePII: this.cfg.capturePII ?? false,
|
|
430
|
+
})
|
|
431
|
+
const body = buildEnvelope(event, this.dsn)
|
|
432
|
+
if (this.cfg.debug) console.debug('[event] error →', this.dsn.ingestUrl, event.event_id)
|
|
433
|
+
this.transport.send(this.dsn.ingestUrl, body, {
|
|
434
|
+
beacon: false,
|
|
435
|
+
contentType: ENVELOPE_CONTENT_TYPE,
|
|
436
|
+
debug: this.cfg.debug,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** errorIdentity is the SAME identity the event stream stamps — the OIDC subject
|
|
441
|
+
* once identify() has run, else the anon id. Never email/PII. */
|
|
442
|
+
private errorIdentity(): ErrorIdentity {
|
|
443
|
+
return {
|
|
444
|
+
userId: this.personId ?? anonId(),
|
|
445
|
+
sessionId: sessionId(),
|
|
446
|
+
product: this.cfg.product,
|
|
447
|
+
release: this.cfg.release ?? readEnv('NEXT_PUBLIC_HANZO_RELEASE'),
|
|
448
|
+
environment: this.cfg.environment ?? readEnv('NODE_ENV'),
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
264
452
|
private enqueue(kind: EventKind, event: string | undefined, extra: Partial<WireEvent>): void {
|
|
265
453
|
if (!this.cfg.enabled) return
|
|
266
454
|
if (!this.started) this.init()
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// React apps use the './react' entry for the provider + hooks + error boundary.
|
|
9
9
|
|
|
10
10
|
export { Analytics, createAnalytics, VERSION, getCohort, getFirstTouch } from './core'
|
|
11
|
+
export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
|
|
12
|
+
export type { ErrorIdentity } from './sentry'
|
|
13
|
+
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
|
11
14
|
export { EVENTS, PAGEVIEW } from './events'
|
|
12
15
|
export type { EventName } from './events'
|
|
13
16
|
export { GOALS, COHORTS } from './goals'
|
|
@@ -22,9 +25,14 @@ export {
|
|
|
22
25
|
export type {
|
|
23
26
|
AnalyticsConfig,
|
|
24
27
|
Attribution,
|
|
28
|
+
CaptureErrorOptions,
|
|
25
29
|
Cohort,
|
|
30
|
+
Dsn,
|
|
26
31
|
EventKind,
|
|
27
32
|
Exception,
|
|
33
|
+
SentryEvent,
|
|
34
|
+
SentryFrame,
|
|
35
|
+
SentryLevel,
|
|
28
36
|
Transport,
|
|
29
37
|
WireEvent,
|
|
30
38
|
} from './types'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { redactSecrets, scrubPII, scrubText, MAX_SCRUB_LEN } from './scrub'
|
|
3
|
+
|
|
4
|
+
describe('redactSecrets (always applied)', () => {
|
|
5
|
+
it('redacts a hanzo key', () => {
|
|
6
|
+
expect(redactSecrets('key=hk-ABCDEFGHIJKLMNOP1234 tail')).toContain('[redacted]')
|
|
7
|
+
expect(redactSecrets('key=hk-ABCDEFGHIJKLMNOP1234 tail')).not.toContain('hk-ABCDEFGHIJKLMNOP1234')
|
|
8
|
+
})
|
|
9
|
+
it('redacts a JWT', () => {
|
|
10
|
+
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdefghij'
|
|
11
|
+
expect(redactSecrets(`token ${jwt}`)).not.toContain(jwt)
|
|
12
|
+
})
|
|
13
|
+
it('redacts a bearer token', () => {
|
|
14
|
+
expect(redactSecrets('Authorization: Bearer abcdef0123456789ABCDEF')).toContain('[redacted]')
|
|
15
|
+
})
|
|
16
|
+
it('redacts openai/stripe/aws/github/google/slack shapes', () => {
|
|
17
|
+
expect(redactSecrets('sk-proj-ABCDEFGHIJKLMNOPQRST')).toContain('[redacted]')
|
|
18
|
+
expect(redactSecrets('sk_live_ABCDEFGHIJKLMNOP1234')).toContain('[redacted]')
|
|
19
|
+
expect(redactSecrets('AKIAABCDEFGHIJKLMNOP')).toContain('[redacted]')
|
|
20
|
+
expect(redactSecrets('ghp_ABCDEFGHIJKLMNOPQRSTUVWX')).toContain('[redacted]')
|
|
21
|
+
expect(redactSecrets('AIzaABCDEFGHIJKLMNOPQRSTUVWXYZ012345')).toContain('[redacted]')
|
|
22
|
+
expect(redactSecrets('xoxb-1111-2222-abcdefghij')).toContain('[redacted]')
|
|
23
|
+
})
|
|
24
|
+
it('redacts credentials embedded in a URL/DSN', () => {
|
|
25
|
+
expect(redactSecrets('postgres://user:s3cretpw@db.host:5432/x')).toContain('[redacted]')
|
|
26
|
+
expect(redactSecrets('postgres://user:s3cretpw@db.host:5432/x')).not.toContain('s3cretpw')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('scrubPII (default; opt-out via capturePII)', () => {
|
|
31
|
+
it('masks emails and IPs', () => {
|
|
32
|
+
expect(scrubPII('from alice@example.com at 192.168.1.7')).toBe('from [email] at [ip]')
|
|
33
|
+
})
|
|
34
|
+
it('scrubText masks by default and retains when capturePII=true', () => {
|
|
35
|
+
expect(scrubText('alice@example.com', false)).toBe('[email]')
|
|
36
|
+
expect(scrubText('alice@example.com', true)).toBe('alice@example.com')
|
|
37
|
+
})
|
|
38
|
+
it('scrubText still redacts secrets even when capturePII=true', () => {
|
|
39
|
+
expect(scrubText('hk-ABCDEFGHIJKLMNOP1234', true)).toContain('[redacted]')
|
|
40
|
+
})
|
|
41
|
+
it('is total on empty/undefined', () => {
|
|
42
|
+
expect(scrubText(undefined)).toBe('')
|
|
43
|
+
expect(scrubText('')).toBe('')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ── denial of service ──────────────────────────────────────────────────────
|
|
48
|
+
//
|
|
49
|
+
// The scrubber runs SYNCHRONOUSLY on the main thread inside captureError, and its
|
|
50
|
+
// input is attacker-influenced: `throw new Error(await res.text())` against an
|
|
51
|
+
// HTML error page is a one-line way to hand it 128KB. The creds-in-URL pattern
|
|
52
|
+
// backtracked quadratically on colon-rich text with no terminating '@' — 4.9s at
|
|
53
|
+
// 32KB, >60s at 128KB. Both the input cap and the bounded pattern are load-bearing.
|
|
54
|
+
|
|
55
|
+
describe('input bounding', () => {
|
|
56
|
+
it('caps input length and says so', () => {
|
|
57
|
+
const out = scrubText('a'.repeat(MAX_SCRUB_LEN * 4))
|
|
58
|
+
expect(out.length).toBeLessThan(MAX_SCRUB_LEN + 64)
|
|
59
|
+
expect(out.endsWith('… [truncated]')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('scrubs pathological colon-rich input in bounded time', () => {
|
|
63
|
+
// The exact shape that blew up: many colons, no '@' to terminate the match.
|
|
64
|
+
const hostile = '<div class="a:b:c">'.repeat(8000) // ~150KB
|
|
65
|
+
const t0 = Date.now()
|
|
66
|
+
const out = scrubText(hostile)
|
|
67
|
+
const ms = Date.now() - t0
|
|
68
|
+
expect(out).toBeTruthy()
|
|
69
|
+
// Was >60s unbounded. Generous ceiling so the test is not flaky on slow CI.
|
|
70
|
+
expect(ms).toBeLessThan(1000)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('still redacts real credentials in a URL', () => {
|
|
74
|
+
expect(scrubText('postgres://user:hunter2@db.internal/app')).toContain('[redacted]')
|
|
75
|
+
expect(scrubText('postgres://user:hunter2@db.internal/app')).not.toContain('hunter2')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── PAN false positives ────────────────────────────────────────────────────
|
|
80
|
+
//
|
|
81
|
+
// The bare digit-run rule redacted every millisecond epoch and order id it saw,
|
|
82
|
+
// destroying the readability of the messages this client exists to deliver. It is
|
|
83
|
+
// now gated on Luhn, which every real card satisfies — so the false positives go
|
|
84
|
+
// away without introducing a false negative.
|
|
85
|
+
|
|
86
|
+
describe('card numbers', () => {
|
|
87
|
+
it('redacts a real (Luhn-valid) card number', () => {
|
|
88
|
+
expect(scrubText('card 4111111111111111 declined')).toContain('[redacted]')
|
|
89
|
+
expect(scrubText('card 4111-1111-1111-1111 declined')).toContain('[redacted]')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('leaves an epoch timestamp alone', () => {
|
|
93
|
+
const out = scrubText('request 1753468800000 timed out')
|
|
94
|
+
expect(out).toBe('request 1753468800000 timed out')
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/scrub.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Client-side redaction for error text — a faithful port of the server's
|
|
2
|
+
// errortracking scrub (o11y pkg/modules/errortracking/implerrortracking/scrub.go),
|
|
3
|
+
// applied BEFORE anything leaves the browser. Two layers, default-secure:
|
|
4
|
+
//
|
|
5
|
+
// - Secret shapes are ALWAYS redacted (sk-… keys, bearer/JWT tokens, DB DSNs,
|
|
6
|
+
// PANs, cloud keys). There is no mode that ships a secret off-device.
|
|
7
|
+
// - PII (email/IP) is scrubbed UNLESS capturePII is explicitly enabled.
|
|
8
|
+
//
|
|
9
|
+
// The server scrubs again — this is defense in depth, not a substitute — but the
|
|
10
|
+
// point is that a Hanzo browser never emits a raw secret/email/IP in the first
|
|
11
|
+
// place. Pure, no I/O.
|
|
12
|
+
|
|
13
|
+
const REDACTED = '[redacted]'
|
|
14
|
+
const EMAIL_MARK = '[email]'
|
|
15
|
+
const IP_MARK = '[ip]'
|
|
16
|
+
|
|
17
|
+
// Secret patterns mirror scrub.go's secretPatterns. Order matters (broad DSN/PAN
|
|
18
|
+
// rules run last). All are applied unconditionally.
|
|
19
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
20
|
+
/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g,
|
|
21
|
+
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, // JWT
|
|
22
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi, // bearer token
|
|
23
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9]{2,}-?[A-Za-z0-9]{12,}/g, // openai-style
|
|
24
|
+
/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g, // stripe
|
|
25
|
+
/\bhk-[A-Za-z0-9]{16,}/g, // hanzo key
|
|
26
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // aws access key id
|
|
27
|
+
/\bASIA[0-9A-Z]{16}\b/g, // aws sts key id
|
|
28
|
+
/\bAIza[0-9A-Za-z_-]{20,}/g, // google api key
|
|
29
|
+
/\bgh[posru]_[A-Za-z0-9]{20,}/g, // github token
|
|
30
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, // slack token
|
|
31
|
+
// Creds in a URL/DSN. The repetition is BOUNDED on purpose: the unbounded form
|
|
32
|
+
// ([^\s:@/]+:[^\s@/]+@) backtracks quadratically on colon-rich text with no
|
|
33
|
+
// terminating '@' — an HTML error page pasted into an error message took 4.9s
|
|
34
|
+
// at 32KB and >60s at 128KB, freezing the main thread from inside captureError.
|
|
35
|
+
// Real userinfo is far below these caps, so bounding costs nothing.
|
|
36
|
+
/[a-zA-Z][a-zA-Z0-9+.-]{0,32}:\/\/[^\s:@/]{1,256}:[^\s@/]{1,256}@/g,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** Candidate card numbers. Gated by Luhn below — the bare digit-run pattern is a
|
|
40
|
+
* false-positive cannon that redacts every millisecond epoch, order id and
|
|
41
|
+
* phone number it sees ("request 1753468800000 timed out" became
|
|
42
|
+
* "request [redacted]timed out"), which destroys the readability of the very
|
|
43
|
+
* error messages this client exists to deliver. */
|
|
44
|
+
const RE_PAN = /\b(?:\d[ -]?){13,19}\b/g
|
|
45
|
+
|
|
46
|
+
/** luhn reports whether a digit string satisfies the Luhn checksum. Every real
|
|
47
|
+
* card number does, so gating redaction on it removes the false positives
|
|
48
|
+
* WITHOUT introducing a false negative — the safe direction for a redactor. */
|
|
49
|
+
function luhn(digits: string): boolean {
|
|
50
|
+
let sum = 0
|
|
51
|
+
let alt = false
|
|
52
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
53
|
+
let d = digits.charCodeAt(i) - 48
|
|
54
|
+
if (alt) {
|
|
55
|
+
d *= 2
|
|
56
|
+
if (d > 9) d -= 9
|
|
57
|
+
}
|
|
58
|
+
sum += d
|
|
59
|
+
alt = !alt
|
|
60
|
+
}
|
|
61
|
+
return sum % 10 === 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** redactPAN removes digit runs that actually check out as card numbers. */
|
|
65
|
+
function redactPAN(s: string): string {
|
|
66
|
+
return s.replace(RE_PAN, (m) => {
|
|
67
|
+
const digits = m.replace(/[ -]/g, '')
|
|
68
|
+
if (digits.length < 13 || digits.length > 19) return m
|
|
69
|
+
return luhn(digits) ? REDACTED : m
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const RE_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
74
|
+
const RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
|
|
75
|
+
const RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g
|
|
76
|
+
|
|
77
|
+
/** redactSecrets removes known secret shapes. Always applied. */
|
|
78
|
+
export function redactSecrets(s: string): string {
|
|
79
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
|
|
80
|
+
return redactPAN(s)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
84
|
+
export function scrubPII(s: string): string {
|
|
85
|
+
s = s.replace(RE_EMAIL, EMAIL_MARK)
|
|
86
|
+
s = s.replace(RE_IPV6, IP_MARK)
|
|
87
|
+
s = s.replace(RE_IPV4, IP_MARK)
|
|
88
|
+
return s
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Longest free-text field this module will scrub. Every pattern here is a regex
|
|
92
|
+
* run synchronously on the main thread, and the input is attacker-influenced —
|
|
93
|
+
* `throw new Error(await res.text())` against an HTML error page is a one-line
|
|
94
|
+
* way to hand us 128KB. Bounding the INPUT bounds the work regardless of which
|
|
95
|
+
* pattern is pathological. 8KB is far beyond any real error message. */
|
|
96
|
+
export const MAX_SCRUB_LEN = 8192
|
|
97
|
+
|
|
98
|
+
/** truncate caps a string at MAX_SCRUB_LEN, marking that it was cut so a reader
|
|
99
|
+
* never mistakes a truncated message for the whole one. */
|
|
100
|
+
export function truncate(s: string, max = MAX_SCRUB_LEN): string {
|
|
101
|
+
return s.length > max ? s.slice(0, max) + '… [truncated]' : s
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** scrubText applies the redaction policy to a free-text field. Input is capped
|
|
105
|
+
* first: unbounded text is a denial-of-service surface, not just a size problem. */
|
|
106
|
+
export function scrubText(s: string | undefined, capturePII = false): string {
|
|
107
|
+
if (!s) return s ?? ''
|
|
108
|
+
s = truncate(s)
|
|
109
|
+
s = redactSecrets(s)
|
|
110
|
+
if (!capturePII) s = scrubPII(s)
|
|
111
|
+
return s
|
|
112
|
+
}
|