@slates/provider 1.0.0-rc.28 → 1.0.0-rc.29

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.
@@ -1,3 +1,11 @@
1
+ import { AuthConfigSecretRedactor } from '../auth/redact';
2
+ import {
3
+ isSecretFieldName,
4
+ redactAuthConfigSecretsInText,
5
+ sanitizeSecretValuesInText,
6
+ splitKeyParts
7
+ } from '../axios/trace';
8
+ import { getCurrentContext } from '../context/hook';
1
9
  import { DEFAULT_CODE, DEFAULT_MESSAGES } from './defaults';
2
10
  import type {
3
11
  SlateErrorInput,
@@ -112,26 +120,273 @@ export let mergeSlateErrorData = (
112
120
  };
113
121
  };
114
122
 
115
- export let sanitizeForBaggage = (value: unknown, depth = 0): unknown => {
116
- if (value == null) return value;
117
- if (depth >= 4) return '[truncated]';
118
- if (typeof value === 'string') {
119
- return value.length > 2_000 ? `${value.slice(0, 2_000)}...[truncated]` : value;
123
+ export let BAGGAGE_TRUNCATED_VALUE = '[truncated]';
124
+ export let BAGGAGE_REDACTED_VALUE = '[redacted]';
125
+ export let BAGGAGE_UNSERIALIZABLE_VALUE = '[unserializable]';
126
+
127
+ // Values nested this deep (root = depth 0) are replaced with the truncated placeholder.
128
+ export let BAGGAGE_DEFAULT_MAX_DEPTH = 4;
129
+ // Upstream response bodies keep deeper structure so nested provider error details
130
+ // (GraphQL errors[].extensions.*, QuickBooks Fault.Error[].Detail, validation error
131
+ // lists) reach message extractors and telemetry.
132
+ export let BAGGAGE_RESPONSE_MAX_DEPTH = 8;
133
+ export let BAGGAGE_MAX_STRING_LENGTH = 2_000;
134
+ export let BAGGAGE_MAX_ARRAY_ITEMS = 20;
135
+ export let BAGGAGE_MAX_OBJECT_KEYS = 30;
136
+ // Upper bound for the JSON-serialized size of one sanitized value. Entries past
137
+ // the budget are dropped (and a string that crosses it is cut), so a single call
138
+ // serializes to at most this many characters plus a small fixed overhead.
139
+ export let BAGGAGE_MAX_SERIALIZED_SIZE = 16_384;
140
+
141
+ let BAGGAGE_STRING_TRUNCATION_SUFFIX = '...[truncated]';
142
+ // Keep budget cuts from producing near-empty strings.
143
+ let BAGGAGE_MIN_STRING_SLICE = 32;
144
+
145
+ // Free-text patterns are scanned over at most this many characters (the kept
146
+ // string length plus a margin), which bounds their cost on adversarial input.
147
+ let BAGGAGE_SCAN_LENGTH = BAGGAGE_MAX_STRING_LENGTH + 100;
148
+ // Literal auth-config secrets are matched over a longer prefix so a long token that
149
+ // straddles the scan boundary is still replaced before the string is cut.
150
+ let BAGGAGE_REDACTOR_SCAN_LENGTH = BAGGAGE_MAX_STRING_LENGTH * 4;
151
+ // Once fewer characters than the smallest entry (`"k":0,`) remain, stop walking keys.
152
+ let BAGGAGE_MIN_ENTRY_COST = 6;
153
+
154
+ export type SanitizeForBaggageOptions = {
155
+ maxDepth?: number;
156
+ maxSerializedSize?: number;
157
+ // Replaces literal auth-config values. Defaults to the current Slate context's auth
158
+ // config when one is available; `null` disables it.
159
+ redactor?: AuthConfigSecretRedactor | null;
160
+ };
161
+
162
+ // Returns a redactor for the current Slate context's auth config, or undefined when
163
+ // no context is active (or it cannot be read).
164
+ export let getCurrentAuthConfigRedactor = (): AuthConfigSecretRedactor | undefined => {
165
+ try {
166
+ let authConfig = getCurrentContext()._getAuthConfigForRedaction();
167
+ return authConfig ? new AuthConfigSecretRedactor(authConfig) : undefined;
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ };
172
+
173
+ // sanitizeErrorText keeps at most this many characters. It scans a small margin past
174
+ // the cut so a credential straddling it is still recognized before the text is cut.
175
+ export let ERROR_TEXT_MAX_LENGTH = BAGGAGE_MAX_STRING_LENGTH * 4;
176
+ let ERROR_TEXT_SCAN_MARGIN = 512;
177
+
178
+ // Free-text scrub for human-readable error text (messages, details): literal
179
+ // auth-config secrets first, then known credential formats and secret-named pairs.
180
+ // Input past ERROR_TEXT_MAX_LENGTH is cut, which also bounds the pattern cost.
181
+ // `redactor`: undefined uses the current Slate context's auth config, null skips it.
182
+ // Never throws; a failing redactor falls back to the pattern scrub alone.
183
+ export let sanitizeErrorText = (
184
+ text: string,
185
+ redactor?: AuthConfigSecretRedactor | null
186
+ ): string => {
187
+ let activeRedactor = redactor === undefined ? getCurrentAuthConfigRedactor() : redactor;
188
+ let truncated = text.length > ERROR_TEXT_MAX_LENGTH;
189
+ let scanned = truncated
190
+ ? text.slice(0, ERROR_TEXT_MAX_LENGTH + ERROR_TEXT_SCAN_MARGIN)
191
+ : text;
192
+
193
+ let result = scanned;
194
+ if (activeRedactor) {
195
+ try {
196
+ result = redactAuthConfigSecretsInText(result, activeRedactor);
197
+ } catch {
198
+ result = scanned;
199
+ }
200
+ }
201
+ result = sanitizeSecretValuesInText(result);
202
+
203
+ return truncated
204
+ ? `${result.slice(0, ERROR_TEXT_MAX_LENGTH)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`
205
+ : result;
206
+ };
207
+
208
+ // Numbers under these keys are themselves secrets (PINs, card data, numeric
209
+ // verification codes, numeric keys and secrets); other numeric and boolean values under
210
+ // secret-named keys (`token: true`, `session: 3`) are flags or counts and are kept.
211
+ let NUMERIC_SECRET_KEY_PATTERN =
212
+ /(?:^|_)(?:pin|passcode|password|passwd|pwd|otp|totp|cvv|cvc|ccv|pan|ssn|card_number|card_code|cc_num(?:ber)?)(?:_|$)|(?:^|_)(?:code|secret|key)$/;
213
+
214
+ // Validation messages under secret-named fields (`{password: "is too short"}`,
215
+ // `{token: ["This field is required."]}`) are kept when they are clearly prose:
216
+ // three or more short words, no token-like word (20+ characters, or letters mixed
217
+ // with digits), bounded length, and validation vocabulary. Multi-word passphrases rarely use that vocabulary, so a string that is
218
+ // merely several words is still redacted.
219
+ let VALIDATION_VOCABULARY_PATTERN =
220
+ /\b(?:required|invalid|must|blank|empty|missing|too (?:short|long|weak|common|simple)|characters?|(?:does not|doesn't|do not|don't) match|cannot be|can't be|not allowed|not valid|at least|at most|incorrect|already (?:taken|used|exists|in use)|has expired|is expired)\b/i;
221
+
222
+ let isValidationProse = (value: string) => {
223
+ let text = value.trim();
224
+ if (text.length > 300) return false;
225
+ let words = text.split(/\s+/);
226
+ if (words.length < 3) return false;
227
+ if (words.some(word => word.length > 20 || (/[a-z]/i.test(word) && /\d/.test(word)))) {
228
+ return false;
229
+ }
230
+ return VALIDATION_VOCABULARY_PATTERN.test(text);
231
+ };
232
+
233
+ let isKeptUnderSecretKey = (key: string, value: unknown) => {
234
+ if (typeof value === 'boolean') return true;
235
+ if (typeof value === 'number')
236
+ return !NUMERIC_SECRET_KEY_PATTERN.test(splitKeyParts(key).join('_'));
237
+ if (typeof value === 'string') return isValidationProse(value);
238
+ if (Array.isArray(value)) {
239
+ return (
240
+ value.length > 0 &&
241
+ value.length <= BAGGAGE_MAX_ARRAY_ITEMS &&
242
+ value.every(item => typeof item === 'string' && isValidationProse(item))
243
+ );
244
+ }
245
+ return false;
246
+ };
247
+
248
+ let jsonLength = (value: string) => JSON.stringify(value).length;
249
+
250
+ type BaggageState = {
251
+ remaining: number;
252
+ redactor?: AuthConfigSecretRedactor;
253
+ };
254
+
255
+ let redactLiteralSecrets = (value: string, redactor?: AuthConfigSecretRedactor) => {
256
+ if (!redactor) return value;
257
+ try {
258
+ return redactAuthConfigSecretsInText(
259
+ value.slice(0, BAGGAGE_REDACTOR_SCAN_LENGTH),
260
+ redactor
261
+ );
262
+ } catch {
263
+ return value;
264
+ }
265
+ };
266
+
267
+ let sanitizeBaggageString = (value: string, state: BaggageState) => {
268
+ // Redact literal auth-config secrets and known secret formats before cutting, so a
269
+ // cut cannot split a token into a fragment the value patterns no longer recognize.
270
+ let literalRedacted = redactLiteralSecrets(value, state.redactor);
271
+ let text = sanitizeSecretValuesInText(literalRedacted.slice(0, BAGGAGE_SCAN_LENGTH));
272
+ if (
273
+ value.length > BAGGAGE_MAX_STRING_LENGTH ||
274
+ literalRedacted.length > BAGGAGE_MAX_STRING_LENGTH ||
275
+ text.length > BAGGAGE_MAX_STRING_LENGTH
276
+ ) {
277
+ text = `${text.slice(0, BAGGAGE_MAX_STRING_LENGTH)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`;
278
+ }
279
+
280
+ let cost = jsonLength(text);
281
+ if (cost > state.remaining) {
282
+ let sliceLength =
283
+ state.remaining - BAGGAGE_STRING_TRUNCATION_SUFFIX.length - 2 - (cost - text.length);
284
+ text =
285
+ sliceLength >= BAGGAGE_MIN_STRING_SLICE
286
+ ? `${text.slice(0, sliceLength)}${BAGGAGE_STRING_TRUNCATION_SUFFIX}`
287
+ : BAGGAGE_TRUNCATED_VALUE;
288
+ cost = jsonLength(text);
289
+ if (cost > state.remaining && text !== BAGGAGE_TRUNCATED_VALUE) {
290
+ text = BAGGAGE_TRUNCATED_VALUE;
291
+ cost = jsonLength(text);
292
+ }
293
+ }
294
+
295
+ state.remaining -= cost;
296
+ return text;
297
+ };
298
+
299
+ let sanitizeBaggageValue = (
300
+ value: unknown,
301
+ depth: number,
302
+ maxDepth: number,
303
+ state: BaggageState
304
+ ): unknown => {
305
+ if (value === undefined) return value;
306
+ if (value === null) {
307
+ state.remaining -= 4;
308
+ return value;
309
+ }
310
+ if (depth >= maxDepth) {
311
+ state.remaining -= jsonLength(BAGGAGE_TRUNCATED_VALUE);
312
+ return BAGGAGE_TRUNCATED_VALUE;
120
313
  }
121
- if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
314
+ if (typeof value === 'string') return sanitizeBaggageString(value, state);
315
+ if (typeof value === 'number' || typeof value === 'boolean') {
316
+ state.remaining -= String(value).length;
122
317
  return value;
123
318
  }
319
+ // JSON.stringify throws on bigint, so it is stored as a string.
320
+ if (typeof value === 'bigint') return sanitizeBaggageString(value.toString(), state);
124
321
  if (Array.isArray(value)) {
125
- return value.slice(0, 20).map(item => sanitizeForBaggage(item, depth + 1));
322
+ state.remaining -= 2;
323
+ let items: unknown[] = [];
324
+ for (let item of value.slice(0, BAGGAGE_MAX_ARRAY_ITEMS)) {
325
+ if (state.remaining <= 0) break;
326
+ state.remaining -= 1;
327
+ if (item === undefined) {
328
+ // Serialized as `null` inside arrays.
329
+ state.remaining -= 4;
330
+ items.push(null);
331
+ continue;
332
+ }
333
+ items.push(sanitizeBaggageValue(item, depth + 1, maxDepth, state));
334
+ }
335
+ return items;
126
336
  }
127
337
  if (isRecord(value)) {
128
- return Object.fromEntries(
129
- Object.entries(value)
130
- .slice(0, 30)
131
- .map(([key, entry]) => [key, sanitizeForBaggage(entry, depth + 1)])
132
- );
338
+ state.remaining -= 2;
339
+ let entries: [string, unknown][] = [];
340
+ for (let key of Object.keys(value).slice(0, BAGGAGE_MAX_OBJECT_KEYS)) {
341
+ if (state.remaining < BAGGAGE_MIN_ENTRY_COST) break;
342
+ // Key, colon, and separator are charged up front; an entry whose key does not
343
+ // fit is skipped so shorter later keys can still be kept.
344
+ let keyCost = jsonLength(key) + 2;
345
+ if (state.remaining - keyCost <= 0) continue;
346
+ state.remaining -= keyCost;
347
+
348
+ try {
349
+ // Property access is per entry so one throwing getter only loses that entry.
350
+ let entry = value[key];
351
+
352
+ if (entry != null && isSecretFieldName(key) && !isKeptUnderSecretKey(key, entry)) {
353
+ state.remaining -= jsonLength(BAGGAGE_REDACTED_VALUE);
354
+ entries.push([key, BAGGAGE_REDACTED_VALUE]);
355
+ continue;
356
+ }
357
+
358
+ entries.push([key, sanitizeBaggageValue(entry, depth + 1, maxDepth, state)]);
359
+ } catch {
360
+ state.remaining -= jsonLength(BAGGAGE_UNSERIALIZABLE_VALUE);
361
+ entries.push([key, BAGGAGE_UNSERIALIZABLE_VALUE]);
362
+ }
363
+ }
364
+ return Object.fromEntries(entries);
365
+ }
366
+ return sanitizeBaggageString(String(value), state);
367
+ };
368
+
369
+ // Bounds and redacts arbitrary values (upstream response bodies, service error data,
370
+ // thrown non-Error values) before they are stored in SlateError baggage, which is
371
+ // serialized to the platform. Secret-named keys are redacted at every depth, strings
372
+ // are scrubbed for credential values and (inside a Slate context) literal auth-config
373
+ // secrets; other personal data is left to platform-side redaction.
374
+ export let sanitizeForBaggage = (
375
+ value: unknown,
376
+ options: SanitizeForBaggageOptions = {}
377
+ ): unknown => {
378
+ try {
379
+ return sanitizeBaggageValue(value, 0, options.maxDepth ?? BAGGAGE_DEFAULT_MAX_DEPTH, {
380
+ remaining: options.maxSerializedSize ?? BAGGAGE_MAX_SERIALIZED_SIZE,
381
+ redactor:
382
+ options.redactor === null
383
+ ? undefined
384
+ : (options.redactor ?? getCurrentAuthConfigRedactor())
385
+ });
386
+ } catch {
387
+ // Throwing getters or Proxy traps must not mask the error being mapped.
388
+ return BAGGAGE_UNSERIALIZABLE_VALUE;
133
389
  }
134
- return String(value);
135
390
  };
136
391
 
137
392
  export let normalizeSlateErrorInput = (input: SlateErrorInput): SlateErrorResponse => {