@hanzo/event 0.3.1 → 0.3.3
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 +81 -17
- package/dist/core-B1XEdWLd.d.cts +296 -0
- package/dist/core-B1XEdWLd.d.ts +296 -0
- package/dist/index.cjs +590 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -6
- package/dist/index.d.ts +177 -6
- package/dist/index.mjs +581 -64
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +413 -25
- 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 +413 -25
- package/dist/react.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +258 -5
- package/src/core.ts +237 -45
- package/src/events.ts +30 -1
- package/src/funnels.test.ts +92 -0
- package/src/funnels.ts +154 -0
- package/src/goals.ts +25 -11
- package/src/index.ts +10 -0
- package/src/scrub.test.ts +96 -0
- package/src/scrub.ts +117 -0
- package/src/sentry.test.ts +312 -0
- package/src/sentry.ts +309 -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/funnels.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The ONE funnel registry. A funnel is DATA — an ordered list of event names —
|
|
2
|
+
// so the products, the Insights UI, and the docs can never drift: every step is
|
|
3
|
+
// a name from EVENTS (funnels.test.ts fails the build otherwise), and a GOAL that
|
|
4
|
+
// has a funnel points AT one of these rather than restating the steps.
|
|
5
|
+
//
|
|
6
|
+
// Why here and not in Insights: the taxonomy is code that all three surfaces
|
|
7
|
+
// already import (`@hanzo/event`). A funnel defined in the read lens could name
|
|
8
|
+
// an event no surface emits; a funnel defined next to EVENTS cannot.
|
|
9
|
+
|
|
10
|
+
import { EVENTS, PAGEVIEW } from './events'
|
|
11
|
+
|
|
12
|
+
/** The emitting surfaces — the closed set of `AnalyticsConfig.product` values.
|
|
13
|
+
* `product` is on every event, so a funnel scopes by product instead of every
|
|
14
|
+
* surface prefixing its event names. */
|
|
15
|
+
export const PRODUCTS = ['site', 'app', 'chat', 'console', 'admin', 'cloud'] as const
|
|
16
|
+
export type ProductId = (typeof PRODUCTS)[number]
|
|
17
|
+
|
|
18
|
+
export interface FunnelStep {
|
|
19
|
+
/** An EVENTS value (or PAGEVIEW). */
|
|
20
|
+
event: string
|
|
21
|
+
/** Human label for the Insights step. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Property equality that qualifies the step, e.g. first_action{action:'api_call'}. */
|
|
24
|
+
where?: { property: string; equals: string }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FunnelDef {
|
|
28
|
+
label: string
|
|
29
|
+
/** Surface(s) the steps are emitted from — matched against the `product` field. */
|
|
30
|
+
products: ProductId[]
|
|
31
|
+
/**
|
|
32
|
+
* How steps are joined:
|
|
33
|
+
* • 'person' — steps join on distinctId (one browser, or one logged-in
|
|
34
|
+
* person across surfaces). The normal case.
|
|
35
|
+
* • 'aggregate' — steps are emitted on DIFFERENT origins by a LOGGED-OUT
|
|
36
|
+
* visitor, so there is no shared id: hanzo.ai, hanzo.app and
|
|
37
|
+
* hanzo.chat each mint their own anonymousId in their own
|
|
38
|
+
* storage. Read these as step-over-step COUNTS, never as a
|
|
39
|
+
* per-person conversion. Honest by construction.
|
|
40
|
+
*/
|
|
41
|
+
join: 'person' | 'aggregate'
|
|
42
|
+
steps: FunnelStep[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const s = (event: string, label: string, where?: FunnelStep['where']): FunnelStep => ({
|
|
46
|
+
event,
|
|
47
|
+
label,
|
|
48
|
+
...(where ? { where } : {}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const FUNNELS = {
|
|
52
|
+
/** hanzo.ai: land → sign up. IAM hosts the form, so `signup_submitted` is the
|
|
53
|
+
* redirect INTO IAM and `signup_completed` is the return at /auth/callback. */
|
|
54
|
+
signup: {
|
|
55
|
+
label: 'Signup',
|
|
56
|
+
products: ['site'],
|
|
57
|
+
join: 'person',
|
|
58
|
+
steps: [
|
|
59
|
+
s(PAGEVIEW, 'Landed'),
|
|
60
|
+
s(EVENTS.SIGNUP_VIEWED, 'Opened signup'),
|
|
61
|
+
s(EVENTS.SIGNUP_SUBMITTED, 'Redirected to Hanzo ID'),
|
|
62
|
+
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
63
|
+
s(EVENTS.FIRST_ACTION, 'First action'),
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/** The developer activation path: an account is worth nothing until a key has
|
|
68
|
+
* made a call. `first_action{action:'api_call'}` is emitted SERVER-SIDE by
|
|
69
|
+
* Cloud on an org's first successful /v1 request — a browser cannot see it. */
|
|
70
|
+
apiActivation: {
|
|
71
|
+
label: 'API activation',
|
|
72
|
+
products: ['site', 'cloud'],
|
|
73
|
+
join: 'person',
|
|
74
|
+
steps: [
|
|
75
|
+
s(EVENTS.SIGNUP_COMPLETED, 'Account created'),
|
|
76
|
+
s(EVENTS.API_KEY_CREATED, 'Key minted'),
|
|
77
|
+
s(EVENTS.FIRST_ACTION, 'First successful API call', {
|
|
78
|
+
property: 'action',
|
|
79
|
+
equals: 'api_call',
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/** Upgrade intent → revenue. `order_completed{kind:'plan'}` is the Sale goal. */
|
|
85
|
+
upgrade: {
|
|
86
|
+
label: 'Upgrade',
|
|
87
|
+
products: ['site', 'app', 'console'],
|
|
88
|
+
join: 'person',
|
|
89
|
+
steps: [
|
|
90
|
+
s(EVENTS.PRICING_VIEWED, 'Viewed pricing'),
|
|
91
|
+
s(EVENTS.PLAN_CLICKED, 'Chose a plan'),
|
|
92
|
+
s(EVENTS.CHECKOUT_STARTED, 'Started checkout'),
|
|
93
|
+
s(EVENTS.ORDER_COMPLETED, 'Paid'),
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** hanzo.app: describe → build → deploy → live URL. The whole product thesis
|
|
98
|
+
* in five steps; `deploy_succeeded` is the moment a live URL exists. */
|
|
99
|
+
appShip: {
|
|
100
|
+
label: 'Describe → ship',
|
|
101
|
+
products: ['app'],
|
|
102
|
+
join: 'person',
|
|
103
|
+
steps: [
|
|
104
|
+
s(PAGEVIEW, 'Landed'),
|
|
105
|
+
s(EVENTS.BUILD_STARTED, 'Described an app'),
|
|
106
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got a working build'),
|
|
107
|
+
s(EVENTS.DEPLOY_STARTED, 'Hit publish'),
|
|
108
|
+
s(EVENTS.DEPLOY_SUCCEEDED, 'Live URL'),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/** hanzo.chat: visit → first message → answer. `generation_completed` is what
|
|
113
|
+
* separates "typed something" from "got value". */
|
|
114
|
+
chatEngage: {
|
|
115
|
+
label: 'Chat engagement',
|
|
116
|
+
products: ['chat'],
|
|
117
|
+
join: 'person',
|
|
118
|
+
steps: [
|
|
119
|
+
s(PAGEVIEW, 'Landed'),
|
|
120
|
+
s(EVENTS.CHAT_STARTED, 'Started a conversation'),
|
|
121
|
+
s(EVENTS.CHAT_MESSAGE_SENT, 'Sent a message'),
|
|
122
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** The cross-surface handoff: the hanzo.ai composer forwards its prompt to
|
|
127
|
+
* hanzo.chat. Two origins, two anonymousIds — so this is an AGGREGATE funnel.
|
|
128
|
+
* The join is the `referrerProduct` property hanzo.chat reads off `?hz_ref=`,
|
|
129
|
+
* which makes the drop-off measurable without any cross-domain identity. */
|
|
130
|
+
siteToChat: {
|
|
131
|
+
label: 'Site → Chat handoff',
|
|
132
|
+
products: ['site', 'chat'],
|
|
133
|
+
join: 'aggregate',
|
|
134
|
+
steps: [
|
|
135
|
+
s(EVENTS.CHAT_STARTED, 'Submitted the hanzo.ai composer', {
|
|
136
|
+
property: 'source',
|
|
137
|
+
equals: 'composer',
|
|
138
|
+
}),
|
|
139
|
+
s(EVENTS.CHAT_STARTED, 'Landed in hanzo.chat', {
|
|
140
|
+
property: 'referrerProduct',
|
|
141
|
+
equals: 'site',
|
|
142
|
+
}),
|
|
143
|
+
s(EVENTS.GENERATION_COMPLETED, 'Got an answer'),
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
} as const satisfies Record<string, FunnelDef>
|
|
147
|
+
|
|
148
|
+
export type FunnelId = keyof typeof FUNNELS
|
|
149
|
+
|
|
150
|
+
/** eventsOf flattens a funnel to its ordered event names — what a goal's `funnel`
|
|
151
|
+
* field carries, so the steps are defined exactly once (here). */
|
|
152
|
+
export function eventsOf(id: FunnelId): string[] {
|
|
153
|
+
return FUNNELS[id].steps.map((step) => step.event)
|
|
154
|
+
}
|
package/src/goals.ts
CHANGED
|
@@ -1,44 +1,58 @@
|
|
|
1
1
|
// Insights goals + cohorts, defined once as data so the console/insights UI and
|
|
2
2
|
// every product agree on what "a Signup", "a Sale", and "upgrade intent" mean.
|
|
3
3
|
// This is the machine-readable spec — the shared source of truth a sync step can
|
|
4
|
-
// push into Insights, and what
|
|
4
|
+
// push into Insights, and what TAXONOMY.md documents.
|
|
5
|
+
//
|
|
6
|
+
// A goal answers "what counts as a conversion". A funnel answers "by what path".
|
|
7
|
+
// The paths live in funnels.ts and a goal REFERENCES one by id: the steps are
|
|
8
|
+
// written down exactly once.
|
|
5
9
|
|
|
6
10
|
import { EVENTS } from './events'
|
|
11
|
+
import { eventsOf, type FunnelId } from './funnels'
|
|
7
12
|
|
|
8
13
|
export interface GoalDef {
|
|
9
14
|
/** Human label shown in Insights. */
|
|
10
15
|
label: string
|
|
11
16
|
/** The event whose occurrence counts as the goal conversion. */
|
|
12
17
|
event: string
|
|
13
|
-
/**
|
|
18
|
+
/** The funnel leading to the goal — an id into FUNNELS (see funnels.ts). */
|
|
19
|
+
funnelId?: FunnelId
|
|
20
|
+
/** The ordered event names of `funnelId`, derived — never hand-written. */
|
|
14
21
|
funnel?: string[]
|
|
15
22
|
/** Optional property equality filter that qualifies the conversion. */
|
|
16
23
|
filter?: { property: string; equals: string }
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
export const GOALS: Record<'signup' | 'sale' | 'upgradeIntent', GoalDef> = {
|
|
20
|
-
// Signup: the conversion is signup_completed
|
|
26
|
+
export const GOALS: Record<'signup' | 'sale' | 'upgradeIntent' | 'activation', GoalDef> = {
|
|
27
|
+
// Signup: the conversion is signup_completed, along the site signup funnel.
|
|
21
28
|
signup: {
|
|
22
29
|
label: 'Signup',
|
|
23
30
|
event: EVENTS.SIGNUP_COMPLETED,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
EVENTS.SIGNUP_SUBMITTED,
|
|
27
|
-
EVENTS.SIGNUP_VERIFIED,
|
|
28
|
-
EVENTS.FIRST_ACTION,
|
|
29
|
-
],
|
|
31
|
+
funnelId: 'signup',
|
|
32
|
+
funnel: eventsOf('signup'),
|
|
30
33
|
},
|
|
31
34
|
// Sale: a completed order qualified as a plan purchase (kind=plan).
|
|
32
35
|
sale: {
|
|
33
36
|
label: 'Sale',
|
|
34
37
|
event: EVENTS.ORDER_COMPLETED,
|
|
38
|
+
funnelId: 'upgrade',
|
|
39
|
+
funnel: eventsOf('upgrade'),
|
|
35
40
|
filter: { property: 'kind', equals: 'plan' },
|
|
36
41
|
},
|
|
37
42
|
// Upgrade intent: a plan click; pricing_viewed is the top of its funnel.
|
|
38
43
|
upgradeIntent: {
|
|
39
44
|
label: 'Upgrade Intent',
|
|
40
45
|
event: EVENTS.PLAN_CLICKED,
|
|
41
|
-
|
|
46
|
+
funnelId: 'upgrade',
|
|
47
|
+
funnel: eventsOf('upgrade'),
|
|
48
|
+
},
|
|
49
|
+
// Activation: the ONE north-star conversion — an account that did the first
|
|
50
|
+
// valuable thing (a successful API call, a live app, a chat answer).
|
|
51
|
+
activation: {
|
|
52
|
+
label: 'Activation',
|
|
53
|
+
event: EVENTS.FIRST_ACTION,
|
|
54
|
+
funnelId: 'apiActivation',
|
|
55
|
+
funnel: eventsOf('apiActivation'),
|
|
42
56
|
},
|
|
43
57
|
}
|
|
44
58
|
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,15 @@
|
|
|
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'
|
|
14
17
|
export type { GoalDef, CohortDef } from './goals'
|
|
18
|
+
export { FUNNELS, PRODUCTS, eventsOf } from './funnels'
|
|
19
|
+
export type { FunnelDef, FunnelStep, FunnelId, ProductId } from './funnels'
|
|
15
20
|
export {
|
|
16
21
|
parseAttribution,
|
|
17
22
|
deriveChannel,
|
|
@@ -22,9 +27,14 @@ export {
|
|
|
22
27
|
export type {
|
|
23
28
|
AnalyticsConfig,
|
|
24
29
|
Attribution,
|
|
30
|
+
CaptureErrorOptions,
|
|
25
31
|
Cohort,
|
|
32
|
+
Dsn,
|
|
26
33
|
EventKind,
|
|
27
34
|
Exception,
|
|
35
|
+
SentryEvent,
|
|
36
|
+
SentryFrame,
|
|
37
|
+
SentryLevel,
|
|
28
38
|
Transport,
|
|
29
39
|
WireEvent,
|
|
30
40
|
} 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,117 @@
|
|
|
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
|
+
// Bounded to the RFC 5321 maxima (local-part 64, domain 255) for the same reason
|
|
74
|
+
// as the creds-in-URL rule: the unbounded form backtracks quadratically across a
|
|
75
|
+
// long run of local-part-legal characters that never reaches an '@' — measured at
|
|
76
|
+
// 20ms on an 8KB worst case, on the main thread, per captured error. No real
|
|
77
|
+
// address is excluded by these caps.
|
|
78
|
+
const RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
79
|
+
const RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
|
|
80
|
+
const RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g
|
|
81
|
+
|
|
82
|
+
/** redactSecrets removes known secret shapes. Always applied. */
|
|
83
|
+
export function redactSecrets(s: string): string {
|
|
84
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
|
|
85
|
+
return redactPAN(s)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
89
|
+
export function scrubPII(s: string): string {
|
|
90
|
+
s = s.replace(RE_EMAIL, EMAIL_MARK)
|
|
91
|
+
s = s.replace(RE_IPV6, IP_MARK)
|
|
92
|
+
s = s.replace(RE_IPV4, IP_MARK)
|
|
93
|
+
return s
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Longest free-text field this module will scrub. Every pattern here is a regex
|
|
97
|
+
* run synchronously on the main thread, and the input is attacker-influenced —
|
|
98
|
+
* `throw new Error(await res.text())` against an HTML error page is a one-line
|
|
99
|
+
* way to hand us 128KB. Bounding the INPUT bounds the work regardless of which
|
|
100
|
+
* pattern is pathological. 8KB is far beyond any real error message. */
|
|
101
|
+
export const MAX_SCRUB_LEN = 8192
|
|
102
|
+
|
|
103
|
+
/** truncate caps a string at MAX_SCRUB_LEN, marking that it was cut so a reader
|
|
104
|
+
* never mistakes a truncated message for the whole one. */
|
|
105
|
+
export function truncate(s: string, max = MAX_SCRUB_LEN): string {
|
|
106
|
+
return s.length > max ? s.slice(0, max) + '… [truncated]' : s
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** scrubText applies the redaction policy to a free-text field. Input is capped
|
|
110
|
+
* first: unbounded text is a denial-of-service surface, not just a size problem. */
|
|
111
|
+
export function scrubText(s: string | undefined, capturePII = false): string {
|
|
112
|
+
if (!s) return s ?? ''
|
|
113
|
+
s = truncate(s)
|
|
114
|
+
s = redactSecrets(s)
|
|
115
|
+
if (!capturePII) s = scrubPII(s)
|
|
116
|
+
return s
|
|
117
|
+
}
|