@hanzo/event 0.3.41 → 0.3.42
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/dist/core.d.ts.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/index.cjs +10 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +10 -4
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +10 -4
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/scrub.d.ts +5 -0
- package/dist/scrub.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core.test.ts +19 -0
- package/src/core.ts +7 -4
- package/src/events.ts +1 -1
- package/src/scrub.test.ts +34 -1
- package/src/scrub.ts +20 -0
package/dist/scrub.d.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
export declare function redactCredentialParams(s: string): string;
|
|
4
4
|
/** redactSecrets removes known secret shapes. Always applied. */
|
|
5
5
|
export declare function redactSecrets(s: string): string;
|
|
6
|
+
/** withoutFragment returns a URL with its fragment removed. A browser never
|
|
7
|
+
* sends the fragment to any server — it is where an app keeps what must stay
|
|
8
|
+
* on the device, a share link's secret among them — so the client does not
|
|
9
|
+
* send it either. */
|
|
10
|
+
export declare function withoutFragment(url: string): string;
|
|
6
11
|
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
7
12
|
export declare function scrubPII(s: string): string;
|
|
8
13
|
/** Longest free-text field this module will scrub. Every pattern here is a regex
|
package/dist/scrub.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scrub.d.ts","sourceRoot":"","sources":["../src/scrub.ts"],"names":[],"mappings":"AA8GA;6EAC6E;AAC7E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD;
|
|
1
|
+
{"version":3,"file":"scrub.d.ts","sourceRoot":"","sources":["../src/scrub.ts"],"names":[],"mappings":"AA8GA;6EAC6E;AAC7E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD;AAYD,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED;;;sBAGsB;AACtB,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK1C;AAED;;;;yEAIyE;AACzE,eAAO,MAAM,aAAa,OAAO,CAAA;AAEjC;4DAC4D;AAC5D,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,SAAgB,GAAG,MAAM,CAE/D;AAED;qFACqF;AACrF,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,UAAQ,GAAG,MAAM,CAM3E"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.42",
|
|
4
4
|
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
25
|
"url": "git+https://github.com/hanzoai/ui.git",
|
|
26
|
-
"directory": "
|
|
26
|
+
"directory": "pkg/event"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"analytics",
|
package/src/core.test.ts
CHANGED
|
@@ -270,6 +270,25 @@ describe('Analytics capture', () => {
|
|
|
270
270
|
})
|
|
271
271
|
})
|
|
272
272
|
|
|
273
|
+
// A share link keeps its secret in the fragment, which a browser never sends
|
|
274
|
+
// to a server. Stamped from window.location.href, it reached /v1/event on
|
|
275
|
+
// every event of the page, and a 43-character base64url secret matches no
|
|
276
|
+
// other secret shape. The fragment is dropped from every stamped location.
|
|
277
|
+
it('never sends a fragment', () => {
|
|
278
|
+
const secret = 'Zq3LwX0p-Tf9_aB7kQmN2rS8vY1cD4eF6gH5jK0lMnO'
|
|
279
|
+
withLocation('https://hanzo.ai/chat/shared#' + secret, 'https://hanzo.ai/login#' + secret, () => {
|
|
280
|
+
const a = mk()
|
|
281
|
+
a.pageview()
|
|
282
|
+
a.capture('$click')
|
|
283
|
+
a.flush()
|
|
284
|
+
for (const e of tx.all) {
|
|
285
|
+
expect(JSON.stringify(e)).not.toContain(secret)
|
|
286
|
+
expect(e.url).toBe('https://hanzo.ai/chat/shared')
|
|
287
|
+
expect(e.referrer).toBe('https://hanzo.ai/login')
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
273
292
|
// A redactor that mangles ordinary URLs would destroy the analytics it
|
|
274
293
|
// exists to protect, so the common case must pass through byte-for-byte.
|
|
275
294
|
it('leaves an ordinary url untouched', () => {
|
package/src/core.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { dsnForProduct } from './dsn'
|
|
|
49
49
|
import { keyForPage } from './org'
|
|
50
50
|
import { EXCEPTION, PAGEVIEW } from './events'
|
|
51
51
|
import { exceptionProperties } from './exception'
|
|
52
|
-
import { scrubText } from './scrub'
|
|
52
|
+
import { scrubText, withoutFragment } from './scrub'
|
|
53
53
|
import {
|
|
54
54
|
buildEnvelope,
|
|
55
55
|
buildSentryEvent,
|
|
@@ -645,10 +645,13 @@ export class Analytics {
|
|
|
645
645
|
// definition of "must not leave the browser", already tested, mirroring the
|
|
646
646
|
// server's. Guarded on presence so an absent field stays absent instead of
|
|
647
647
|
// becoming the empty string that `host` derivation reads as a page.
|
|
648
|
+
//
|
|
649
|
+
// The fragment goes first and whole: nothing after `#` is ever the server's
|
|
650
|
+
// (see withoutFragment), so it is dropped rather than scrubbed.
|
|
648
651
|
const capturePII = this.cfg.capturePII ?? false
|
|
649
|
-
if (wire.url) wire.url = scrubText(wire.url, capturePII)
|
|
650
|
-
if (wire.path) wire.path = scrubText(wire.path, capturePII)
|
|
651
|
-
if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
|
|
652
|
+
if (wire.url) wire.url = scrubText(withoutFragment(wire.url), capturePII)
|
|
653
|
+
if (wire.path) wire.path = scrubText(withoutFragment(wire.path), capturePII)
|
|
654
|
+
if (wire.referrer) wire.referrer = scrubText(withoutFragment(wire.referrer), capturePII)
|
|
652
655
|
return wire
|
|
653
656
|
}
|
|
654
657
|
|
package/src/events.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* and there is still exactly one definition.
|
|
10
10
|
*
|
|
11
11
|
* Nothing about the API moved. Every existing import keeps working, the names
|
|
12
|
-
* and their values are unchanged, and `
|
|
12
|
+
* and their values are unchanged, and `pkg/events/test/catalog.mjs` fails the
|
|
13
13
|
* build if a name and its meaning ever disagree.
|
|
14
14
|
*
|
|
15
15
|
* The naming convention lives with the names, in @hanzo/events and TAXONOMY.md:
|
package/src/scrub.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { redactSecrets, scrubPII, scrubText, MAX_SCRUB_LEN } from './scrub'
|
|
2
|
+
import { redactSecrets, scrubPII, scrubText, withoutFragment, MAX_SCRUB_LEN } from './scrub'
|
|
3
3
|
|
|
4
4
|
describe('redactSecrets (always applied)', () => {
|
|
5
5
|
it('redacts a hanzo key', () => {
|
|
@@ -121,3 +121,36 @@ describe('credential params in a URL', () => {
|
|
|
121
121
|
expect(out).toContain('page=2')
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('a 256-bit base64url secret', () => {
|
|
126
|
+
const secret = 'Zq3LwX0p-Tf9_aB7kQmN2rS8vY1cD4eF6gH5jK0lMnO'
|
|
127
|
+
it('is redacted wherever it stands alone', () => {
|
|
128
|
+
expect(secret).toHaveLength(43)
|
|
129
|
+
for (const s of [secret, 'token ' + secret + ' expired', '/chat/shared/' + secret, '{"token":"' + secret + '"}']) {
|
|
130
|
+
expect(redactSecrets(s)).not.toContain(secret)
|
|
131
|
+
expect(redactSecrets(s)).toContain('[redacted]')
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
it('is redacted with no digit in it, and behind an encoded fragment mark', () => {
|
|
135
|
+
const letters = 'ZqLwXp-Tf_aBkQmNrSvYcDeFgHjKlMnOpQrStUvWxYz'
|
|
136
|
+
expect(letters).toHaveLength(43)
|
|
137
|
+
expect(redactSecrets('token=' + letters)).not.toContain(letters)
|
|
138
|
+
const returnUrl = 'https://hanzo.ai/login?next=%2Fchat%2Fshared%23' + secret
|
|
139
|
+
expect(redactSecrets(returnUrl)).not.toContain(secret)
|
|
140
|
+
expect(redactSecrets(returnUrl)).toContain('%23[redacted]')
|
|
141
|
+
})
|
|
142
|
+
it('leaves a longer or shorter run, and a word-only run, alone', () => {
|
|
143
|
+
expect(redactSecrets(secret + 'x')).toBe(secret + 'x')
|
|
144
|
+
expect(redactSecrets(secret.slice(1))).toBe(secret.slice(1))
|
|
145
|
+
const slug = 'the-quick-brown-fox-jumps-over-the-lazy-dogs'.slice(0, 43)
|
|
146
|
+
expect(slug).toHaveLength(43)
|
|
147
|
+
expect(redactSecrets(slug)).toBe(slug)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('withoutFragment', () => {
|
|
152
|
+
it('drops everything from the first #', () => {
|
|
153
|
+
expect(withoutFragment('https://hanzo.ai/chat/shared#abc#def')).toBe('https://hanzo.ai/chat/shared')
|
|
154
|
+
expect(withoutFragment('https://hanzo.ai/pricing?plan=pro')).toBe('https://hanzo.ai/pricing?plan=pro')
|
|
155
|
+
})
|
|
156
|
+
})
|
package/src/scrub.ts
CHANGED
|
@@ -114,13 +114,33 @@ export function redactCredentialParams(s: string): string {
|
|
|
114
114
|
return s.replace(RE_CREDENTIAL_PARAM, (_m, prefix: string) => prefix + REDACTED)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// A 256-bit secret written as unpadded base64url is exactly 43 characters of
|
|
118
|
+
// [A-Za-z0-9_-] — the shape of a share-link secret, and of any other opaque key
|
|
119
|
+
// minted the same way. The run must stand alone — nothing of the same alphabet
|
|
120
|
+
// on either side, a percent-escape such as the `%23` of an encoded `#` counting
|
|
121
|
+
// as a boundary — so it never fires inside a longer token, and it must mix upper
|
|
122
|
+
// and lower case, which a random 32 bytes does with near certainty and an
|
|
123
|
+
// ordinary word or slug does not. No lookbehind, so it runs on every engine the
|
|
124
|
+
// client ships to.
|
|
125
|
+
const RE_SECRET43 = /(^|[^A-Za-z0-9_-]|%[0-9A-Fa-f]{2})((?=[A-Za-z0-9_-]{0,42}[a-z])(?=[A-Za-z0-9_-]{0,42}[A-Z])[A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/g
|
|
126
|
+
|
|
117
127
|
/** redactSecrets removes known secret shapes. Always applied. */
|
|
118
128
|
export function redactSecrets(s: string): string {
|
|
119
129
|
s = redactCredentialParams(s)
|
|
120
130
|
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
|
|
131
|
+
s = s.replace(RE_SECRET43, (_m, lead: string) => lead + REDACTED)
|
|
121
132
|
return redactPAN(s)
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/** withoutFragment returns a URL with its fragment removed. A browser never
|
|
136
|
+
* sends the fragment to any server — it is where an app keeps what must stay
|
|
137
|
+
* on the device, a share link's secret among them — so the client does not
|
|
138
|
+
* send it either. */
|
|
139
|
+
export function withoutFragment(url: string): string {
|
|
140
|
+
const at = url.indexOf('#')
|
|
141
|
+
return at < 0 ? url : url.slice(0, at)
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
125
145
|
export function scrubPII(s: string): string {
|
|
126
146
|
s = s.replace(RE_EMAIL, EMAIL_MARK)
|