@slates/provider 1.0.0-rc.27 → 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.
- package/dist/index.cjs +2305 -1876
- package/dist/index.d.cts +82 -8
- package/dist/index.d.ts +82 -8
- package/dist/index.module.js +2295 -1876
- package/package.json +1 -1
- package/src/action/getFileUrlTool.ts +2 -1
- package/src/action/triggerGroup.ts +65 -3
- package/src/auth/redact.test.ts +60 -2
- package/src/auth/redact.ts +59 -23
- package/src/axios/trace.test.ts +326 -1
- package/src/axios/trace.ts +334 -14
- package/src/error/api.test.ts +123 -1
- package/src/error/api.ts +148 -0
- package/src/error/axios.ts +48 -13
- package/src/error/utils.test.ts +654 -0
- package/src/error/utils.ts +268 -13
package/src/error/utils.ts
CHANGED
|
@@ -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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 => {
|