@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/src/core.ts CHANGED
@@ -1,13 +1,27 @@
1
- // The framework-agnostic event client. Buffers events and flushes them as ONE
2
- // batch through the ONE Hanzo Cloud ingestion front door:
1
+ // The framework-agnostic event client. ONE API surface over TWO orthogonal
2
+ // planes, sharing one session and one identity:
3
3
  //
4
- // POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped }
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
- // It NEVER sends the org/tenant: Cloud resolves that server-side (from the
7
- // validated session, or the signed publishable key) and stamps it. The client
8
- // only supplies its own visitor identity. Errors are just events (type:'error')
9
- // on the same stream — one client, one pipe, lensed server-side into product
10
- // analytics (insights), web analytics (analytics), and error tracking (sentry).
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', so a captured exception reaches the
23
- // error-tracking lens. (A four-field {event,distinctId,time,properties} object has
24
- // no `type`, so it can never be lensed as an error this batched Event wire is
25
- // the one that lights up all three lenses.)
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 const VERSION = '0.3.0'
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(url: string, body: string, opts: { beacon: boolean; token?: string; ingestKey?: string }): void {
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: 'application/json' }))
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': 'application/json' }
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 become type:'error' events on the same stream, which Cloud
165
- // stamps event_type='error' the sentry.hanzo.ai lens.
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 records an exception as a first-class error event the ONE
209
- * error path (subsumes @sentry). A caught error, an unhandled rejection, a
210
- * React render error, or a manual report all become a type:'error' event on the
211
- * same stream; Cloud folds the exception into properties.$exception and stamps
212
- * event_type='error', so it surfaces in the error-tracking lens. Never throws
213
- * back into the app; errors are higher-signal than pageviews, so it flushes
214
- * promptly (a crash may unload the page moments later). */
215
- captureError(
216
- err: unknown,
217
- context?: { handled?: boolean; properties?: Record<string, unknown> },
218
- ): void {
219
- const ex = normalizeError(err)
220
- ex.handled = context?.handled ?? true
221
- this.enqueue('error', ex.message, { error: ex, properties: context?.properties })
222
- this.flush()
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 = JSON.stringify({ batch })
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, { beacon: useBeacon, token, ingestKey: key })
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
+ }