@global-torque/invest-core 0.3.0 → 0.4.0
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/CHANGELOG.md +13 -0
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/analytics/__tests__/analyticsBody.test.ts +156 -21
- package/src/analytics/analyticsBody.ts +269 -195
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 candidate
|
|
4
|
+
|
|
5
|
+
- Reissued the framework cohort for the breaking redemption domain contract;
|
|
6
|
+
analytics and policy exports remain unchanged.
|
|
7
|
+
|
|
8
|
+
## 0.3.1 candidate
|
|
9
|
+
|
|
10
|
+
- Retain user, business, numeric, URL, and diagnostic analytics values while
|
|
11
|
+
redacting exact known credential fields across objects, JSON, FormData, and
|
|
12
|
+
URLSearchParams.
|
|
13
|
+
- Preserve duplicate form values, circular/binary markers, and method-specific
|
|
14
|
+
request-body normalization without mutating caller data.
|
|
15
|
+
|
|
3
16
|
## 0.3.0 candidate
|
|
4
17
|
|
|
5
18
|
- Require Node `^24.21.0`; Node 22 is no longer supported.
|
package/README.md
CHANGED
|
@@ -27,7 +27,9 @@ TypeScript browser and type targets and additionally provide exact generated
|
|
|
27
27
|
candidate packer emits only these three Node files; all other exports remain
|
|
28
28
|
source based.
|
|
29
29
|
|
|
30
|
-
- `analytics/analyticsBody`: analytics request body normalization
|
|
30
|
+
- `analytics/analyticsBody`: analytics request body normalization with exact
|
|
31
|
+
credential-field redaction. User, business, numeric, URL, and diagnostic
|
|
32
|
+
values are retained unless assigned to a known credential field.
|
|
31
33
|
- `decimal/canonicalDecimal`: numeric(38,18) canonical-string validation, exact scaled-integer arithmetic, comparison, and display formatting.
|
|
32
34
|
- `evm/walletInfo`: EVM wallet status-only response normalization and deposit-address extraction.
|
|
33
35
|
- `filer/publicImage`: pure public filer image URL and `srcset` builders with injected `filerUrl`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@global-torque/invest-core",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Pure investment policy, calculations, formatters, and schema helpers.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@types/lodash": "^4.17.24",
|
|
70
|
-
"@global-torque/domain-types": "0.
|
|
70
|
+
"@global-torque/domain-types": "0.4.0",
|
|
71
71
|
"ajv": "^8.20.0",
|
|
72
72
|
"ajv-errors": "^3.0.0",
|
|
73
73
|
"ajv-formats": "^3.0.1",
|
|
@@ -6,45 +6,180 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
normalizeAnalyticsBody,
|
|
8
8
|
normalizeAnalyticsBodyForMethod,
|
|
9
|
+
sanitizeAnalyticsText,
|
|
10
|
+
sanitizeAnalyticsUrl,
|
|
9
11
|
} from '../analyticsBody';
|
|
10
12
|
|
|
11
13
|
describe('analyticsBody', () => {
|
|
12
|
-
it('
|
|
13
|
-
|
|
14
|
+
it('removes exact credential fields while preserving user and business data', () => {
|
|
15
|
+
const input = {
|
|
14
16
|
email: 'user@example.com',
|
|
17
|
+
firstName: 'Jamie',
|
|
18
|
+
address: '42 Main Street',
|
|
19
|
+
longId: '12345678901234567890',
|
|
20
|
+
token_symbol: 'USDC',
|
|
21
|
+
token_count: 12,
|
|
22
|
+
tokenNote: 'token is a business concept here',
|
|
15
23
|
password: 'secret',
|
|
16
|
-
code: '123456',
|
|
17
24
|
nested: {
|
|
18
|
-
|
|
25
|
+
jwtToken: 'jwt-secret',
|
|
26
|
+
auth_code: 'auth-secret',
|
|
27
|
+
status_code: 200,
|
|
19
28
|
note: 'Contact user@example.com before submitting',
|
|
20
|
-
tokenNote: 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwMTIzNDU2Nzg5MCJ9.signaturepart',
|
|
21
|
-
keep: true,
|
|
22
29
|
},
|
|
23
|
-
}
|
|
24
|
-
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
expect(normalizeAnalyticsBody(input)).toEqual({
|
|
33
|
+
email: 'user@example.com',
|
|
34
|
+
firstName: 'Jamie',
|
|
35
|
+
address: '42 Main Street',
|
|
36
|
+
longId: '12345678901234567890',
|
|
37
|
+
token_symbol: 'USDC',
|
|
38
|
+
token_count: 12,
|
|
39
|
+
tokenNote: 'token is a business concept here',
|
|
25
40
|
password: '[redacted]',
|
|
26
|
-
code: '[redacted]',
|
|
27
41
|
nested: {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
jwtToken: '[redacted]',
|
|
43
|
+
auth_code: '[redacted]',
|
|
44
|
+
status_code: 200,
|
|
45
|
+
note: 'Contact user@example.com before submitting',
|
|
32
46
|
},
|
|
33
47
|
});
|
|
48
|
+
expect(input.password).toBe('secret');
|
|
49
|
+
expect(input.nested.jwtToken).toBe('jwt-secret');
|
|
34
50
|
});
|
|
35
51
|
|
|
36
|
-
it('normalizes JSON strings and
|
|
37
|
-
expect(normalizeAnalyticsBody('{"
|
|
38
|
-
|
|
52
|
+
it('normalizes JSON strings and retains GET bodies', () => {
|
|
53
|
+
expect(normalizeAnalyticsBody('{"email":"user@example.com","token":"secret","keep":1}')).toEqual({
|
|
54
|
+
email: 'user@example.com',
|
|
55
|
+
token: '[redacted]',
|
|
39
56
|
keep: 1,
|
|
40
57
|
});
|
|
41
58
|
expect(normalizeAnalyticsBody('not-json')).toEqual({});
|
|
59
|
+
expect(normalizeAnalyticsBodyForMethod('GET', { keep: true })).toEqual({ keep: true });
|
|
60
|
+
expect(normalizeAnalyticsBodyForMethod('POST', { keep: true })).toEqual({ keep: true });
|
|
61
|
+
expect(normalizeAnalyticsBodyForMethod('POST', undefined)).toEqual({});
|
|
42
62
|
});
|
|
43
63
|
|
|
44
|
-
it('
|
|
45
|
-
expect(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
it('redacts code only for the known csrf authentication record shape', () => {
|
|
65
|
+
expect(normalizeAnalyticsBody({ method: 'code', csrf_token: 'csrf', code: 'one', state: 'two' })).toEqual({
|
|
66
|
+
method: 'code',
|
|
67
|
+
csrf_token: '[redacted]',
|
|
68
|
+
code: '[redacted]',
|
|
69
|
+
state: 'two',
|
|
70
|
+
});
|
|
71
|
+
expect(normalizeAnalyticsBody({ method: 'code', code: 'keep', state: 'two' })).toEqual({
|
|
72
|
+
method: 'code',
|
|
73
|
+
code: 'keep',
|
|
74
|
+
state: 'two',
|
|
75
|
+
});
|
|
76
|
+
expect(normalizeAnalyticsBody({ method: 'code', csrf_token: 'csrf', status_code: 200 })).toEqual({
|
|
77
|
+
method: 'code',
|
|
78
|
+
csrf_token: '[redacted]',
|
|
79
|
+
status_code: 200,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(normalizeAnalyticsBody('{"method":"code","csrfToken":"csrf","code":"one","state":"two"}')).toEqual({
|
|
83
|
+
method: 'code',
|
|
84
|
+
csrfToken: '[redacted]',
|
|
85
|
+
code: '[redacted]',
|
|
86
|
+
state: 'two',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('preserves duplicate FormData and URLSearchParams values while masking credentials', () => {
|
|
91
|
+
const formData = new FormData();
|
|
92
|
+
formData.append('email', 'user@example.com');
|
|
93
|
+
formData.append('email', 'second@example.com');
|
|
94
|
+
formData.append('method', 'code');
|
|
95
|
+
formData.append('csrf_token', 'csrf');
|
|
96
|
+
formData.append('code', 'one');
|
|
97
|
+
formData.append('code', 'two');
|
|
98
|
+
expect(normalizeAnalyticsBody(formData)).toMatchObject({
|
|
99
|
+
email: ['user@example.com', 'second@example.com'],
|
|
100
|
+
method: 'code',
|
|
101
|
+
csrf_token: '[redacted]',
|
|
102
|
+
code: ['[redacted]', '[redacted]'],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
params.append('email', 'user@example.com');
|
|
107
|
+
params.append('email', 'second@example.com');
|
|
108
|
+
params.append('token_symbol', 'USDC');
|
|
109
|
+
params.append('token', 'secret');
|
|
110
|
+
expect(normalizeAnalyticsBody(params)).toEqual({
|
|
111
|
+
email: ['user@example.com', 'second@example.com'],
|
|
112
|
+
token_symbol: 'USDC',
|
|
113
|
+
token: '[redacted]',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const authParams = new URLSearchParams('method=code&csrf_token=csrf&code=one&code=two&state=state');
|
|
117
|
+
expect(normalizeAnalyticsBody(authParams)).toEqual({
|
|
118
|
+
method: 'code',
|
|
119
|
+
csrf_token: '[redacted]',
|
|
120
|
+
code: ['[redacted]', '[redacted]'],
|
|
121
|
+
state: 'state',
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles cycles before arrays and preserves binary markers', () => {
|
|
126
|
+
const value: Record<string, unknown> = { name: 'user@example.com' };
|
|
127
|
+
const array: unknown[] = [];
|
|
128
|
+
array.push(array);
|
|
129
|
+
value.array = array;
|
|
130
|
+
value.file = new Blob(['contents']);
|
|
131
|
+
expect(normalizeAnalyticsBody(value)).toEqual({
|
|
132
|
+
name: 'user@example.com',
|
|
133
|
+
array: ['[circular]'],
|
|
134
|
+
file: '[binary]',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('selectively preserves URL context and masks credential locations', () => {
|
|
139
|
+
const url = 'https://user:pass@example.test/path/token/CANARY?%74oken=CANARY&email=user@example.test&tag=one&tag=two#section';
|
|
140
|
+
const sanitized = sanitizeAnalyticsUrl(url);
|
|
141
|
+
expect(sanitized).toContain('https://example.test/path/token/[redacted]');
|
|
142
|
+
expect(sanitized).toContain('email=user%40example.test');
|
|
143
|
+
expect(sanitized).toContain('tag=one&tag=two');
|
|
144
|
+
expect(sanitized).toContain('#section');
|
|
145
|
+
expect(sanitized).not.toContain('CANARY');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('sanitizes credential assignments and bearer values without broad PII matching', () => {
|
|
149
|
+
const text = 'user@example.com amount=123456789012 tokenNote=keep token=CANARY url=https://example.test/path?token=CANARY&email=user@example.com';
|
|
150
|
+
const sanitized = sanitizeAnalyticsText(text);
|
|
151
|
+
expect(sanitized).toContain('user@example.com');
|
|
152
|
+
expect(sanitized).toContain('123456789012');
|
|
153
|
+
expect(sanitized).toContain('tokenNote=keep');
|
|
154
|
+
expect(sanitized).not.toContain('token=CANARY');
|
|
155
|
+
expect(sanitized).not.toContain('?token=CANARY');
|
|
156
|
+
expect(sanitizeAnalyticsText('Failed /offers?%74oken=CANARY&email=user@example.test'))
|
|
157
|
+
.toBe('Failed /offers?token=%5Bredacted%5D&email=user%40example.test');
|
|
158
|
+
expect(sanitizeAnalyticsText('Open ../offers?email=user@example.test'))
|
|
159
|
+
.toBe('Open ../offers?email=user%40example.test');
|
|
160
|
+
expect(normalizeAnalyticsBody({
|
|
161
|
+
redirect_url: '/auth?%74oken=CANARY&email=user@example.test',
|
|
162
|
+
})).toEqual({
|
|
163
|
+
redirect_url: '/auth?token=%5Bredacted%5D&email=user%40example.test',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('sanitizes nested text assignments and exact credential headers only', () => {
|
|
168
|
+
expect(sanitizeAnalyticsText('{"nested":{"password":"TEST_SECRET"},"email":"user@example.com"}'))
|
|
169
|
+
.toBe('{"nested":{"password":"[redacted]"},"email":"user@example.com"}');
|
|
170
|
+
expect(sanitizeAnalyticsText('https://host/path#%74oken=TEST_SECRET&tab=activity'))
|
|
171
|
+
.toBe('https://host/path#token=%5Bredacted%5D&tab=activity');
|
|
172
|
+
expect(sanitizeAnalyticsText('Cookie: session=TEST_SECRET; next=SECOND_SECRET'))
|
|
173
|
+
.toBe('Cookie: [redacted]');
|
|
174
|
+
expect(sanitizeAnalyticsText('preferred-cookie: chocolate email=user@example.com'))
|
|
175
|
+
.toBe('preferred-cookie: chocolate email=user@example.com');
|
|
176
|
+
expect(sanitizeAnalyticsText('message="failed password=CANARY for a@example.test"'))
|
|
177
|
+
.toBe('message="failed password=[redacted] for a@example.test"');
|
|
178
|
+
expect(sanitizeAnalyticsText('{"authorization":"Bearer CANARY","password":"SECOND_SECRET","email":"user@example.com"}'))
|
|
179
|
+
.toBe('{"authorization":"Bearer [redacted]","password":"[redacted]","email":"user@example.com"}');
|
|
180
|
+
expect(sanitizeAnalyticsText('{"proxy_authorization":"Basic CANARY","token":"SECOND_SECRET","email":"user@example.com"}'))
|
|
181
|
+
.toBe('{"proxy_authorization":"Basic [redacted]","token":"[redacted]","email":"user@example.com"}');
|
|
182
|
+
expect(sanitizeAnalyticsText('password="prefix[redacted]SECRET" token=abc%5Bredacted%5DSECRET'))
|
|
183
|
+
.toBe('password="[redacted]" token=[redacted]');
|
|
49
184
|
});
|
|
50
185
|
});
|
|
@@ -1,224 +1,314 @@
|
|
|
1
1
|
import type { AnalyticsBody } from '@global-torque/domain-types/analyticsTypes';
|
|
2
2
|
|
|
3
|
-
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
4
3
|
const REDACTED_VALUE = '[redacted]';
|
|
5
|
-
const REDACTED_BODY: AnalyticsBody = { redacted: true };
|
|
6
|
-
const SENSITIVE_EXACT_KEYS = new Set([
|
|
7
|
-
'address',
|
|
8
|
-
'address1',
|
|
9
|
-
'address2',
|
|
10
|
-
'city',
|
|
11
|
-
'country',
|
|
12
|
-
'account_holder_name',
|
|
13
|
-
'code',
|
|
14
|
-
'dob',
|
|
15
|
-
'email',
|
|
16
|
-
'first_name',
|
|
17
|
-
'full_name',
|
|
18
|
-
'ip_address',
|
|
19
|
-
'last_name',
|
|
20
|
-
'middle_name',
|
|
21
|
-
'postal_code',
|
|
22
|
-
'state',
|
|
23
|
-
'wallet',
|
|
24
|
-
'user_browser',
|
|
25
|
-
'zip',
|
|
26
|
-
'zip_code',
|
|
27
|
-
]);
|
|
28
4
|
const MAX_STRING_LENGTH = 500;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Credential names are matched exactly after converting camel, kebab and
|
|
8
|
+
* spaced names to snake case. Business fields such as `token_symbol`,
|
|
9
|
+
* `token_count`, and `tokenNote` remain available.
|
|
10
|
+
*/
|
|
11
|
+
const CREDENTIAL_KEYS = new Set([
|
|
33
12
|
'password',
|
|
34
|
-
'
|
|
13
|
+
'create_password',
|
|
14
|
+
'repeat_password',
|
|
15
|
+
'current_password',
|
|
16
|
+
'new_password',
|
|
17
|
+
'confirm_password',
|
|
18
|
+
'token',
|
|
19
|
+
'jwt_token',
|
|
20
|
+
'access_token',
|
|
21
|
+
'refresh_token',
|
|
22
|
+
'id_token',
|
|
23
|
+
'auth_token',
|
|
24
|
+
'session_token',
|
|
25
|
+
'session_token_exchange_code',
|
|
26
|
+
'csrf',
|
|
27
|
+
'csrf_token',
|
|
28
|
+
'xsrf_token',
|
|
29
|
+
'authorization',
|
|
30
|
+
'proxy_authorization',
|
|
31
|
+
'cookie',
|
|
32
|
+
'set_cookie',
|
|
33
|
+
'api_key',
|
|
34
|
+
'x_api_key',
|
|
35
35
|
'secret',
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
36
|
+
'client_secret',
|
|
37
|
+
'private_key',
|
|
38
|
+
'seed_phrase',
|
|
39
|
+
'mnemonic',
|
|
40
|
+
'passcode',
|
|
41
|
+
'otp',
|
|
39
42
|
'totp',
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
'pin',
|
|
44
|
+
'auth_code',
|
|
45
|
+
'verification_code',
|
|
46
|
+
'recovery_code',
|
|
47
|
+
'totp_code',
|
|
48
|
+
'link_token',
|
|
49
|
+
'challenge_signature',
|
|
50
|
+
'owner_signature',
|
|
51
|
+
]);
|
|
52
|
+
const COMPACT_CREDENTIAL_KEYS = new Set(
|
|
53
|
+
[...CREDENTIAL_KEYS].map((key) => key.replaceAll('_', '')),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const BEARER_TOKEN_PATTERN = /\b(Bearer|Basic)\s+[^\s,;&"'<>]+/giu;
|
|
57
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>]+/giu;
|
|
58
|
+
const RELATIVE_URL_PATTERN = /(^|[\s("'=])((?:\/{1,2}|\.\.?\/)[^\s"'<>]+)/gmu;
|
|
59
|
+
const CREDENTIAL_HEADER_PATTERN = /(^|[^"'A-Za-z0-9_-])(cookie|set-cookie|authorization|proxy-authorization)\s*:\s*[^\r\n]*/gimu;
|
|
60
|
+
|
|
61
|
+
/** Convert supported key spellings to one exact comparison form. */
|
|
62
|
+
export const normalizeAnalyticsKey = (key: string): string => key
|
|
63
|
+
.trim()
|
|
64
|
+
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
65
|
+
.replace(/[^A-Za-z\d]+/g, '_')
|
|
66
|
+
.replace(/^_+|_+$/g, '')
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
|
|
69
|
+
export const isCredentialKey = (key: string): boolean =>
|
|
70
|
+
CREDENTIAL_KEYS.has(normalizeAnalyticsKey(key))
|
|
71
|
+
|| COMPACT_CREDENTIAL_KEYS.has(normalizeAnalyticsKey(key).replaceAll('_', ''));
|
|
72
|
+
|
|
73
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
74
|
+
const CREDENTIAL_KEY_SPELLINGS = new Set(
|
|
75
|
+
[...CREDENTIAL_KEYS].flatMap((key) => [
|
|
76
|
+
key,
|
|
77
|
+
key.replace(/_([a-z])/gu, (_, letter: string) => letter.toUpperCase()),
|
|
78
|
+
key.replaceAll('_', '-'),
|
|
79
|
+
key.replaceAll('_', ''),
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
const CREDENTIAL_ASSIGNMENT_PATTERN = new RegExp(
|
|
83
|
+
`(?<![A-Za-z0-9_-])(["']?)(${[...CREDENTIAL_KEY_SPELLINGS].map(escapeRegExp).join('|')})\\1(\\s*[:=]\\s*)(?:(['"])((?:\\\\.|(?!\\4)[\\s\\S])*)\\4|([^\\s,;&}{"']+))`,
|
|
84
|
+
'giu',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/** Mask a credential assignment while retaining its label and surrounding text. */
|
|
88
|
+
const maskCredentialAssignments = (value: string): string => value.replace(
|
|
89
|
+
CREDENTIAL_ASSIGNMENT_PATTERN,
|
|
90
|
+
(
|
|
91
|
+
match,
|
|
92
|
+
quote: string,
|
|
93
|
+
key: string,
|
|
94
|
+
separator: string,
|
|
95
|
+
valueQuote?: string,
|
|
96
|
+
quotedValue?: string,
|
|
97
|
+
unquotedValue?: string,
|
|
98
|
+
) => {
|
|
99
|
+
const rawValue = quotedValue ?? unquotedValue ?? '';
|
|
100
|
+
if (
|
|
101
|
+
rawValue === REDACTED_VALUE
|
|
102
|
+
|| /^%5bredacted%5d$/iu.test(rawValue)
|
|
103
|
+
|| /^(?:Bearer|Basic)\s+\[redacted\]$/iu.test(rawValue)
|
|
104
|
+
) return match;
|
|
105
|
+
return `${quote}${key}${quote}${separator}${valueQuote ? `${valueQuote}${REDACTED_VALUE}${valueQuote}` : REDACTED_VALUE}`;
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const maskCredentialHeaders = (value: string): string => value.replace(
|
|
110
|
+
CREDENTIAL_HEADER_PATTERN,
|
|
111
|
+
(match, prefix: string, key: string) => `${prefix}${key}: [redacted]`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const hasAbsoluteScheme = (value: string): boolean => /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value);
|
|
115
|
+
const hasProtocolRelativePrefix = (value: string): boolean => value.startsWith('//');
|
|
116
|
+
|
|
117
|
+
const redactPathCredentialValues = (pathname: string): string => {
|
|
118
|
+
const segments = pathname.split('/');
|
|
119
|
+
return segments.map((segment, index) => {
|
|
120
|
+
const previous = segments[index - 1] ?? '';
|
|
121
|
+
let decodedPrevious = previous;
|
|
122
|
+
try {
|
|
123
|
+
decodedPrevious = decodeURIComponent(previous);
|
|
124
|
+
} catch {
|
|
125
|
+
// Keep the encoded segment when it is not valid URI encoding.
|
|
126
|
+
}
|
|
127
|
+
return isCredentialKey(decodedPrevious) ? '[redacted]' : segment;
|
|
128
|
+
}).join('/');
|
|
129
|
+
};
|
|
46
130
|
|
|
131
|
+
const maskCredentialFragment = (fragment: string): string => {
|
|
132
|
+
if (!fragment) return fragment;
|
|
133
|
+
const rawFragment = fragment.slice(1);
|
|
134
|
+
if (rawFragment.includes('=')) {
|
|
135
|
+
const params = new URLSearchParams(rawFragment);
|
|
136
|
+
const entries = [...params.entries()];
|
|
137
|
+
if (entries.some(([key]) => isCredentialKey(key))) {
|
|
138
|
+
const next = new URLSearchParams();
|
|
139
|
+
entries.forEach(([key, value]) => next.append(key, isCredentialKey(key) ? REDACTED_VALUE : value));
|
|
140
|
+
return `#${next.toString()}`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return maskCredentialAssignments(fragment);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Preserve useful URL data while removing only known credential locations. */
|
|
147
|
+
export const sanitizeAnalyticsUrl = (raw: string): string => {
|
|
148
|
+
if (!raw) return '';
|
|
149
|
+
|
|
150
|
+
const absolute = hasAbsoluteScheme(raw);
|
|
151
|
+
const protocolRelative = hasProtocolRelativePrefix(raw);
|
|
152
|
+
const base = 'https://analytics.local';
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const url = new URL(raw, base);
|
|
156
|
+
url.username = '';
|
|
157
|
+
url.password = '';
|
|
158
|
+
url.pathname = redactPathCredentialValues(url.pathname);
|
|
159
|
+
|
|
160
|
+
const entries = [...url.searchParams.entries()];
|
|
161
|
+
url.search = '';
|
|
162
|
+
entries.forEach(([key, value]) => {
|
|
163
|
+
url.searchParams.append(key, isCredentialKey(key) ? '[redacted]' : value);
|
|
164
|
+
});
|
|
165
|
+
url.hash = maskCredentialFragment(url.hash);
|
|
166
|
+
|
|
167
|
+
const serialized = url.toString();
|
|
168
|
+
if (absolute) return serialized;
|
|
169
|
+
if (protocolRelative) return `//${url.host}${url.pathname}${url.search}${url.hash}`;
|
|
170
|
+
|
|
171
|
+
const originalHadPath = raw.startsWith('/') || (!raw.startsWith('?') && !raw.startsWith('#'));
|
|
172
|
+
const relativePrefix = raw.match(/^(?:\.\.?\/)+/u)?.[0] ?? '';
|
|
173
|
+
const relativePath = originalHadPath ? url.pathname.replace(/^\//u, '') : '';
|
|
174
|
+
return `${raw.startsWith('/') ? url.pathname : `${relativePrefix}${relativePath}`}${url.search}${url.hash}`;
|
|
175
|
+
} catch {
|
|
176
|
+
return maskCredentialAssignments(raw);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sanitize free-form diagnostics without treating ordinary user/business data
|
|
182
|
+
* as sensitive. Embedded URLs are processed first so useful URL context stays.
|
|
183
|
+
*/
|
|
47
184
|
export const sanitizeAnalyticsText = (
|
|
48
185
|
raw: unknown,
|
|
49
186
|
maxLength = MAX_STRING_LENGTH,
|
|
50
187
|
): string => {
|
|
51
|
-
if (raw == null)
|
|
52
|
-
return '';
|
|
53
|
-
}
|
|
188
|
+
if (raw == null) return '';
|
|
54
189
|
|
|
55
190
|
let sanitized = String(raw)
|
|
56
|
-
.replace(
|
|
57
|
-
.replace(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.replace(
|
|
191
|
+
.replace(URL_PATTERN, (url) => sanitizeAnalyticsUrl(url))
|
|
192
|
+
.replace(RELATIVE_URL_PATTERN, (match, prefix: string, url: string) => (
|
|
193
|
+
`${prefix}${sanitizeAnalyticsUrl(url)}`
|
|
194
|
+
))
|
|
195
|
+
.replace(BEARER_TOKEN_PATTERN, '$1 [redacted]');
|
|
196
|
+
sanitized = maskCredentialHeaders(sanitized);
|
|
197
|
+
sanitized = maskCredentialAssignments(sanitized);
|
|
61
198
|
|
|
62
199
|
if (sanitized.length > maxLength) {
|
|
63
|
-
sanitized = `${sanitized.slice(0, maxLength - 3)}...`;
|
|
200
|
+
sanitized = `${sanitized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
64
201
|
}
|
|
65
202
|
|
|
66
203
|
return sanitized;
|
|
67
204
|
};
|
|
68
205
|
|
|
69
|
-
const isSensitiveKey = (key: string): boolean => {
|
|
70
|
-
const normalized = key.trim().toLowerCase();
|
|
71
|
-
|
|
72
|
-
if (!normalized) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (SENSITIVE_EXACT_KEYS.has(normalized)) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (SENSITIVE_KEY_SUBSTRINGS.some((fragment) => normalized.includes(fragment))) {
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
normalized === 'token'
|
|
86
|
-
|| normalized.endsWith('token')
|
|
87
|
-
|| normalized.startsWith('token')
|
|
88
|
-
|| normalized.endsWith('_token')
|
|
89
|
-
|| normalized.startsWith('token_')
|
|
90
|
-
) {
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
normalized === 'otp'
|
|
96
|
-
|| normalized === 'pin'
|
|
97
|
-
|| normalized.endsWith('_otp')
|
|
98
|
-
|| normalized.endsWith('_pin')
|
|
99
|
-
|| normalized.endsWith('otp')
|
|
100
|
-
|| normalized.endsWith('pin')
|
|
101
|
-
|| (
|
|
102
|
-
(normalized.endsWith('_code') || normalized.endsWith('code'))
|
|
103
|
-
&& (
|
|
104
|
-
normalized.includes('otp')
|
|
105
|
-
|| normalized.includes('totp')
|
|
106
|
-
|| normalized.includes('pin')
|
|
107
|
-
|| normalized.includes('verification')
|
|
108
|
-
|| normalized.includes('recovery')
|
|
109
|
-
)
|
|
110
|
-
)
|
|
111
|
-
);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
206
|
const isBlobSupported = () => typeof Blob !== 'undefined';
|
|
115
207
|
const isFormDataSupported = () => typeof FormData !== 'undefined';
|
|
208
|
+
const isUrlSearchParamsSupported = () => typeof URLSearchParams !== 'undefined';
|
|
116
209
|
const isFileSupported = () => typeof File !== 'undefined';
|
|
117
210
|
|
|
118
211
|
const normalizeBinaryValue = (value: Blob): string => {
|
|
119
|
-
if (isFileSupported() && value instanceof File && value.name.trim())
|
|
120
|
-
return '[binary]';
|
|
121
|
-
}
|
|
122
|
-
|
|
212
|
+
if (isFileSupported() && value instanceof File && value.name.trim()) return '[binary]';
|
|
123
213
|
return '[binary]';
|
|
124
214
|
};
|
|
125
215
|
|
|
126
|
-
const
|
|
127
|
-
value
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const normalized = Object.entries(value).reduce<AnalyticsBody>((result, [key, item]) => {
|
|
133
|
-
result[key] = isSensitiveKey(key)
|
|
134
|
-
? REDACTED_VALUE
|
|
135
|
-
: normalizeBodyValue(item, seen);
|
|
136
|
-
return result;
|
|
137
|
-
}, {});
|
|
138
|
-
|
|
139
|
-
seen.delete(value);
|
|
140
|
-
return normalized;
|
|
216
|
+
const isAuthCodeRecord = (value: Record<string, unknown>): boolean => {
|
|
217
|
+
const hasMethodCode = Object.entries(value).some(([key, item]) =>
|
|
218
|
+
normalizeAnalyticsKey(key) === 'method' && item === 'code');
|
|
219
|
+
const hasCsrfToken = Object.keys(value).some((key) =>
|
|
220
|
+
normalizeAnalyticsKey(key) === 'csrf_token');
|
|
221
|
+
return hasMethodCode && hasCsrfToken;
|
|
141
222
|
};
|
|
142
223
|
|
|
143
|
-
const
|
|
144
|
-
target: AnalyticsBody,
|
|
145
|
-
key: string,
|
|
146
|
-
value: unknown,
|
|
147
|
-
) => {
|
|
224
|
+
const appendRepeatedValue = (target: AnalyticsBody, key: string, value: unknown): void => {
|
|
148
225
|
const currentValue = target[key];
|
|
149
|
-
|
|
150
226
|
if (typeof currentValue === 'undefined') {
|
|
151
227
|
target[key] = value;
|
|
152
|
-
|
|
228
|
+
} else {
|
|
229
|
+
target[key] = Array.isArray(currentValue)
|
|
230
|
+
? [...currentValue, value]
|
|
231
|
+
: [currentValue, value];
|
|
153
232
|
}
|
|
154
|
-
|
|
155
|
-
target[key] = Array.isArray(currentValue)
|
|
156
|
-
? [...currentValue, value]
|
|
157
|
-
: [currentValue, value];
|
|
158
233
|
};
|
|
159
234
|
|
|
160
|
-
const normalizeFormData = (
|
|
161
|
-
formData
|
|
162
|
-
|
|
163
|
-
)
|
|
235
|
+
const normalizeFormData = (formData: FormData, seen: WeakSet<object>): AnalyticsBody => {
|
|
236
|
+
seen.add(formData);
|
|
237
|
+
const entries = [...formData.entries()];
|
|
238
|
+
const hasMethodCode = entries.some(([key, value]) => normalizeAnalyticsKey(key) === 'method' && value === 'code');
|
|
239
|
+
const hasCsrfToken = entries.some(([key]) => normalizeAnalyticsKey(key) === 'csrf_token');
|
|
164
240
|
const normalized: AnalyticsBody = {};
|
|
165
241
|
|
|
166
|
-
|
|
167
|
-
|
|
242
|
+
entries.forEach(([key, value]) => {
|
|
243
|
+
const redactCode = normalizeAnalyticsKey(key) === 'code' && hasMethodCode && hasCsrfToken;
|
|
244
|
+
appendRepeatedValue(
|
|
168
245
|
normalized,
|
|
169
246
|
key,
|
|
170
|
-
|
|
247
|
+
isCredentialKey(key) || redactCode ? '[redacted]' : normalizeBodyValue(value, seen),
|
|
171
248
|
);
|
|
172
|
-
}
|
|
249
|
+
});
|
|
173
250
|
|
|
251
|
+
seen.delete(formData);
|
|
174
252
|
return normalized;
|
|
175
253
|
};
|
|
176
254
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (typeof value === 'string') {
|
|
186
|
-
return sanitizeAnalyticsText(value);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (typeof value === 'boolean') {
|
|
190
|
-
return value;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (typeof value === 'number') {
|
|
194
|
-
return Number.isFinite(value) ? value : null;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (typeof value === 'bigint') {
|
|
198
|
-
return value.toString();
|
|
199
|
-
}
|
|
255
|
+
const normalizeUrlSearchParams = (params: URLSearchParams, seen: WeakSet<object>): AnalyticsBody => {
|
|
256
|
+
seen.add(params);
|
|
257
|
+
const entries = [...params.entries()];
|
|
258
|
+
const hasMethodCode = entries.some(([key, value]) => normalizeAnalyticsKey(key) === 'method' && value === 'code');
|
|
259
|
+
const hasCsrfToken = entries.some(([key]) => normalizeAnalyticsKey(key) === 'csrf_token');
|
|
260
|
+
const normalized: AnalyticsBody = {};
|
|
200
261
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
262
|
+
entries.forEach(([key, value]) => {
|
|
263
|
+
const redactCode = normalizeAnalyticsKey(key) === 'code' && hasMethodCode && hasCsrfToken;
|
|
264
|
+
appendRepeatedValue(
|
|
265
|
+
normalized,
|
|
266
|
+
key,
|
|
267
|
+
isCredentialKey(key) || redactCode ? '[redacted]' : normalizeBodyValue(value, seen),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
204
270
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
271
|
+
seen.delete(params);
|
|
272
|
+
return normalized;
|
|
273
|
+
};
|
|
208
274
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
275
|
+
const normalizeObjectValue = (
|
|
276
|
+
value: Record<string, unknown>,
|
|
277
|
+
seen: WeakSet<object>,
|
|
278
|
+
): AnalyticsBody => {
|
|
279
|
+
seen.add(value);
|
|
280
|
+
const redactCode = isAuthCodeRecord(value);
|
|
281
|
+
const normalized = Object.entries(value).reduce<AnalyticsBody>((result, [key, item]) => {
|
|
282
|
+
result[key] = isCredentialKey(key) || (redactCode && normalizeAnalyticsKey(key) === 'code')
|
|
283
|
+
? '[redacted]'
|
|
284
|
+
: normalizeBodyValue(item, seen);
|
|
285
|
+
return result;
|
|
286
|
+
}, {});
|
|
287
|
+
seen.delete(value);
|
|
288
|
+
return normalized;
|
|
289
|
+
};
|
|
212
290
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
291
|
+
const normalizeBodyValue = (value: unknown, seen: WeakSet<object>): unknown => {
|
|
292
|
+
if (value == null) return null;
|
|
293
|
+
if (typeof value === 'string') return sanitizeAnalyticsText(value);
|
|
294
|
+
if (typeof value === 'boolean') return value;
|
|
295
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
296
|
+
if (typeof value === 'bigint') return value.toString();
|
|
297
|
+
if (value instanceof Date) return value.toISOString();
|
|
298
|
+
if (isBlobSupported() && value instanceof Blob) return normalizeBinaryValue(value);
|
|
216
299
|
|
|
217
300
|
if (typeof value === 'object') {
|
|
218
|
-
if (seen.has(value as object))
|
|
219
|
-
|
|
301
|
+
if (seen.has(value as object)) return '[circular]';
|
|
302
|
+
if (Array.isArray(value)) {
|
|
303
|
+
seen.add(value);
|
|
304
|
+
const normalized = value.map((item) => normalizeBodyValue(item, seen));
|
|
305
|
+
seen.delete(value);
|
|
306
|
+
return normalized;
|
|
307
|
+
}
|
|
308
|
+
if (isFormDataSupported() && value instanceof FormData) return normalizeFormData(value, seen);
|
|
309
|
+
if (isUrlSearchParamsSupported() && value instanceof URLSearchParams) {
|
|
310
|
+
return normalizeUrlSearchParams(value, seen);
|
|
220
311
|
}
|
|
221
|
-
|
|
222
312
|
return normalizeObjectValue(value as Record<string, unknown>, seen);
|
|
223
313
|
}
|
|
224
314
|
|
|
@@ -226,16 +316,11 @@ const normalizeBodyValue = (
|
|
|
226
316
|
};
|
|
227
317
|
|
|
228
318
|
export const normalizeAnalyticsBody = (value: unknown): AnalyticsBody => {
|
|
229
|
-
if (value == null) {
|
|
230
|
-
return {};
|
|
231
|
-
}
|
|
319
|
+
if (value == null) return {};
|
|
232
320
|
|
|
233
321
|
if (typeof value === 'string') {
|
|
234
322
|
const trimmedValue = value.trim();
|
|
235
|
-
if (!trimmedValue) {
|
|
236
|
-
return {};
|
|
237
|
-
}
|
|
238
|
-
|
|
323
|
+
if (!trimmedValue) return {};
|
|
239
324
|
try {
|
|
240
325
|
return normalizeAnalyticsBody(JSON.parse(trimmedValue));
|
|
241
326
|
} catch {
|
|
@@ -243,28 +328,17 @@ export const normalizeAnalyticsBody = (value: unknown): AnalyticsBody => {
|
|
|
243
328
|
}
|
|
244
329
|
}
|
|
245
330
|
|
|
246
|
-
|
|
247
|
-
|
|
331
|
+
const seen = new WeakSet<object>();
|
|
332
|
+
if (isFormDataSupported() && value instanceof FormData) return normalizeFormData(value, seen);
|
|
333
|
+
if (isUrlSearchParamsSupported() && value instanceof URLSearchParams) {
|
|
334
|
+
return normalizeUrlSearchParams(value, seen);
|
|
248
335
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return {};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return normalizeObjectValue(value as Record<string, unknown>, new WeakSet<object>());
|
|
336
|
+
if (typeof value !== 'object' || Array.isArray(value)) return {};
|
|
337
|
+
return normalizeObjectValue(value as Record<string, unknown>, seen);
|
|
255
338
|
};
|
|
256
339
|
|
|
340
|
+
/** Normalize every method consistently; method remains part of the public API. */
|
|
257
341
|
export const normalizeAnalyticsBodyForMethod = (
|
|
258
|
-
|
|
342
|
+
_method: string | undefined,
|
|
259
343
|
value: unknown,
|
|
260
|
-
): AnalyticsBody =>
|
|
261
|
-
if (!method || !MUTATION_METHODS.has(method.toUpperCase())) {
|
|
262
|
-
return {};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (value == null || (typeof value === 'string' && value.trim() === '')) {
|
|
266
|
-
return {};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return { ...REDACTED_BODY };
|
|
270
|
-
};
|
|
344
|
+
): AnalyticsBody => normalizeAnalyticsBody(value);
|