@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/axios/trace.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX,
|
|
4
|
+
AuthConfigSecretRedactor
|
|
5
|
+
} from '../auth/redact';
|
|
3
6
|
import type { SlatePublicContext } from '../context';
|
|
4
7
|
|
|
5
8
|
export interface SlateHttpTraceTextBody {
|
|
@@ -84,6 +87,9 @@ let CORE_SECRET_KEY_ATOMS = [
|
|
|
84
87
|
'app[-_.]?key',
|
|
85
88
|
'x[-_.]?api[-_.]?key',
|
|
86
89
|
'x[-_.]?app[-_.]?key',
|
|
90
|
+
// HubSpot legacy API key query param
|
|
91
|
+
'hapikey',
|
|
92
|
+
'access[-_.]?key',
|
|
87
93
|
|
|
88
94
|
// OAuth client credentials
|
|
89
95
|
'client[-_.]?secret',
|
|
@@ -149,6 +155,9 @@ let CORE_SECRET_KEY_ATOMS = [
|
|
|
149
155
|
'session[-_.]?id',
|
|
150
156
|
'sessionid',
|
|
151
157
|
'sid',
|
|
158
|
+
'sessid',
|
|
159
|
+
'jsessionid',
|
|
160
|
+
'phpsessid',
|
|
152
161
|
'cookie',
|
|
153
162
|
'cookies',
|
|
154
163
|
'set[-_.]?cookie',
|
|
@@ -255,18 +264,119 @@ let NON_SECRET_KEY_EXACT = new Set<string>([
|
|
|
255
264
|
'kid'
|
|
256
265
|
]);
|
|
257
266
|
|
|
258
|
-
let isSecretKeyName = (name: string): boolean => {
|
|
267
|
+
export let isSecretKeyName = (name: string): boolean => {
|
|
259
268
|
let normalized = name.toLowerCase();
|
|
260
269
|
if (NON_SECRET_KEY_EXACT.has(normalized)) return false;
|
|
261
270
|
return SECRET_KEY_PATTERN.test(normalized);
|
|
262
271
|
};
|
|
263
272
|
|
|
273
|
+
// Whole query/form names that carry a credential only in URLs: Google's `?key=AIza...`.
|
|
274
|
+
// Matched exactly so compound names such as `sort_key` or `partition_key` stay visible.
|
|
275
|
+
let URL_FORM_EXACT_SECRET_KEYS = new Set(['key']);
|
|
276
|
+
|
|
264
277
|
let isUrlFormSecretKeyName = (name: string): boolean => {
|
|
265
278
|
let normalized = name.toLowerCase();
|
|
266
279
|
if (NON_SECRET_KEY_EXACT.has(normalized)) return false;
|
|
280
|
+
if (URL_FORM_EXACT_SECRET_KEYS.has(normalized)) return true;
|
|
267
281
|
return URL_FORM_SECRET_KEY_PATTERN.test(normalized);
|
|
268
282
|
};
|
|
269
283
|
|
|
284
|
+
let splitCamelCase = (key: string) => key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
|
|
285
|
+
|
|
286
|
+
// `githubToken` -> ['github', 'token'], `ASP.NET_SessionId` -> ['asp', 'net', 'session', 'id'].
|
|
287
|
+
export let splitKeyParts = (key: string) =>
|
|
288
|
+
splitCamelCase(key)
|
|
289
|
+
.toLowerCase()
|
|
290
|
+
.split(/[^a-z0-9]+/)
|
|
291
|
+
.filter(Boolean);
|
|
292
|
+
|
|
293
|
+
// A compound key ending in one of these describes a secret (its count, expiry, URL,
|
|
294
|
+
// type, error, ...) rather than holding it: `tokenCount`, `sessionTimeout`,
|
|
295
|
+
// `authError`, `signatureValid`, `authorizationUrl`, `token_type`, `tokenExpiresAt`.
|
|
296
|
+
let NON_SECRET_KEY_SUFFIXES = new Set([
|
|
297
|
+
'count',
|
|
298
|
+
'limit',
|
|
299
|
+
'timeout',
|
|
300
|
+
'expired',
|
|
301
|
+
'expires',
|
|
302
|
+
'expiry',
|
|
303
|
+
'at',
|
|
304
|
+
'valid',
|
|
305
|
+
'url',
|
|
306
|
+
'uri',
|
|
307
|
+
'error',
|
|
308
|
+
'type',
|
|
309
|
+
'id',
|
|
310
|
+
'ids',
|
|
311
|
+
'name'
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
// Pagination cursors, not credentials.
|
|
315
|
+
let NON_SECRET_FIELD_KEYS = new Set([
|
|
316
|
+
'page_token',
|
|
317
|
+
'next_page_token',
|
|
318
|
+
'prev_page_token',
|
|
319
|
+
'previous_page_token',
|
|
320
|
+
'next_token',
|
|
321
|
+
'continuation_token'
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
// `<qualifier>_id` keys that are credentials despite the `id` suffix: session IDs
|
|
325
|
+
// (`sessionId`, `user_session_ids`, `ASP.NET_SessionId`) and `secret_id`.
|
|
326
|
+
let SECRET_ID_QUALIFIERS = new Set(['session', 'sess', 'secret']);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Split-camelCase-aware secret field classifier shared by error baggage, auth-config
|
|
330
|
+
* path checks, and the prose `key: value` scan. Matches camelCase keys
|
|
331
|
+
* (`githubToken`, `apiSecret`, `accessKey`) and bare `session` keys, but not keys that
|
|
332
|
+
* describe a secret (`tokenCount`, `token_type`) or pagination cursors (`pageToken`).
|
|
333
|
+
*/
|
|
334
|
+
export let isSecretFieldName = (key: string): boolean => {
|
|
335
|
+
let parts = splitKeyParts(key);
|
|
336
|
+
if (parts.length === 0) return false;
|
|
337
|
+
let normalized = parts.join('_');
|
|
338
|
+
if (NON_SECRET_FIELD_KEYS.has(normalized)) return false;
|
|
339
|
+
|
|
340
|
+
let last = parts[parts.length - 1] ?? '';
|
|
341
|
+
if (parts.length > 1 && NON_SECRET_KEY_SUFFIXES.has(last)) {
|
|
342
|
+
return (
|
|
343
|
+
(last === 'id' || last === 'ids') &&
|
|
344
|
+
SECRET_ID_QUALIFIERS.has(parts[parts.length - 2] ?? '')
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return isSecretKeyName(key) || isSecretKeyName(normalized) || parts.includes('session');
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Auth-config fields named for the credential's role rather than its kind:
|
|
352
|
+
// `subscriptionKey`, `writeKey`, `integrationKey`, `provisioningHash`.
|
|
353
|
+
let AUTH_CONFIG_CREDENTIAL_SUFFIXES = new Set(['key', 'keys', 'hash']);
|
|
354
|
+
|
|
355
|
+
// `tokenHeader: 'authorization'` names a header; it does not hold a credential.
|
|
356
|
+
let HEADER_NAME_VALUE_PATTERN = /^[A-Za-z]+(?:-[A-Za-z]+)*$/;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether an auth-config value at `path` (e.g. `oauth.accessToken`, `apiKeys.0`) is a
|
|
360
|
+
* credential worth replacing in diagnostics. Only secret-named fields qualify: auth
|
|
361
|
+
* configs also hold account IDs, emails, subdomains, scopes, and token types, and
|
|
362
|
+
* replacing those wherever they appear destroys useful context. Field names are chosen
|
|
363
|
+
* by integration authors and describe credentials consistently (`token`, `apiKey`,
|
|
364
|
+
* `clientSecret`, `password`, `*Key`), while an entropy fallback would also hit
|
|
365
|
+
* high-entropy non-secrets such as `clientId`, `tenantId`, or `cloudId`.
|
|
366
|
+
*/
|
|
367
|
+
export let isAuthConfigSecretValue = (secret: string, path: string): boolean => {
|
|
368
|
+
let segment = path
|
|
369
|
+
.split('.')
|
|
370
|
+
.reverse()
|
|
371
|
+
.find(part => !/^\d+$/.test(part));
|
|
372
|
+
if (!segment) return false;
|
|
373
|
+
|
|
374
|
+
let parts = splitKeyParts(segment);
|
|
375
|
+
let last = parts[parts.length - 1] ?? '';
|
|
376
|
+
if (last === 'header' && HEADER_NAME_VALUE_PATTERN.test(secret)) return false;
|
|
377
|
+
return isSecretFieldName(segment) || AUTH_CONFIG_CREDENTIAL_SUFFIXES.has(last);
|
|
378
|
+
};
|
|
379
|
+
|
|
270
380
|
// Patterns that identify well-known secret values regardless of the surrounding key.
|
|
271
381
|
// Keeps low false-positive risk by matching formats that are unlikely to occur otherwise.
|
|
272
382
|
let KNOWN_SECRET_VALUE_PATTERNS: RegExp[] = [
|
|
@@ -360,6 +470,14 @@ let isTextContentType = (contentType?: string) =>
|
|
|
360
470
|
!contentType ||
|
|
361
471
|
/(^text\/)|json|xml|html|javascript|graphql|x-www-form-urlencoded|svg/.test(contentType);
|
|
362
472
|
|
|
473
|
+
let redactKnownSecretValues = (text: string) => {
|
|
474
|
+
let result = text;
|
|
475
|
+
for (let pattern of KNOWN_SECRET_VALUE_PATTERNS) {
|
|
476
|
+
result = result.replace(pattern, REDACTED_VALUE);
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
};
|
|
480
|
+
|
|
363
481
|
let isSafeHeaderName = (name: string) => {
|
|
364
482
|
let normalized = name.toLowerCase();
|
|
365
483
|
if (isSecretKeyName(normalized)) return false;
|
|
@@ -383,16 +501,14 @@ let sanitizeFreeText = (text: string, redactor?: AuthConfigSecretRedactor) => {
|
|
|
383
501
|
|
|
384
502
|
// Literal auth-config secret values are replaced first -- this catches secrets sitting
|
|
385
503
|
// under a key name the heuristics below don't recognize as secret-like.
|
|
386
|
-
let result = redactor ? redactor.redact(text) : text;
|
|
504
|
+
let result = redactor ? redactor.redact(text, isAuthConfigSecretValue) : text;
|
|
387
505
|
|
|
388
506
|
result = result.replace(
|
|
389
507
|
HTTP_AUTH_SCHEME_PATTERN,
|
|
390
508
|
(_match, scheme: string) => `${scheme} ${REDACTED_VALUE}`
|
|
391
509
|
);
|
|
392
510
|
|
|
393
|
-
|
|
394
|
-
result = result.replace(pattern, REDACTED_VALUE);
|
|
395
|
-
}
|
|
511
|
+
result = redactKnownSecretValues(result);
|
|
396
512
|
|
|
397
513
|
result = result.replace(
|
|
398
514
|
GENERIC_KEY_VALUE_PATTERN,
|
|
@@ -409,6 +525,185 @@ let sanitizeFreeText = (text: string, redactor?: AuthConfigSecretRedactor) => {
|
|
|
409
525
|
return result;
|
|
410
526
|
};
|
|
411
527
|
|
|
528
|
+
// A credential after an auth scheme word, so messages like "Invalid token provided"
|
|
529
|
+
// or "Basic plan required" are left intact (see looksLikeCredential).
|
|
530
|
+
let PROSE_SAFE_AUTH_SCHEME_PATTERN =
|
|
531
|
+
/\b(bearer|basic|digest|token|negotiate|apikey)(\s+)([A-Za-z0-9_\-.~+/=]{8,})/gi;
|
|
532
|
+
|
|
533
|
+
// A value counts as a credential when it carries a digit or token punctuation, or mixes
|
|
534
|
+
// case past its first character (base64 such as `dXNlcjpwYXNz`). Plain lowercase or
|
|
535
|
+
// Title-case words are prose. `key=value` fragments are left to the key/value scan so
|
|
536
|
+
// "token code=404" is not mistaken for a credential.
|
|
537
|
+
let looksLikeCredential = (value: string) => {
|
|
538
|
+
if (/^[A-Za-z][A-Za-z0-9_\-.]*=/.test(value)) return false;
|
|
539
|
+
return /[0-9_\-.~+/=]/.test(value) || (/[a-z]/.test(value) && /[A-Z]/.test(value.slice(1)));
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// Credential header names in free text. The value runs to the closing quote when it is
|
|
543
|
+
// quoted, otherwise to the end of the line or the next `&`; redactCredentialHeaderValue
|
|
544
|
+
// decides how much of it is the credential.
|
|
545
|
+
let CREDENTIAL_HEADER_VALUE_PATTERN =
|
|
546
|
+
/\b(proxy-authorization|authorization|x-api-key|api[-_]?key|set-cookie|cookie)(\s*[:=]\s*)(?:(["'])((?:\\.|(?!\3)[^\\\r\n])*)|([^\r\n&]*))/gi;
|
|
547
|
+
|
|
548
|
+
let AUTH_SCHEME_WORD_PATTERN = /^(?:bearer|basic|digest|token|negotiate|apikey)$/i;
|
|
549
|
+
|
|
550
|
+
let COOKIE_HEADER_NAME_PATTERN = /^(?:set-)?cookie$/i;
|
|
551
|
+
let AUTHORIZATION_HEADER_NAME_PATTERN = /^(?:proxy-)?authorization$/i;
|
|
552
|
+
|
|
553
|
+
// `sid=1; theme=dark`: a cookie pair followed by any chained pairs.
|
|
554
|
+
let COOKIE_PAIRS_PATTERN = /^[^\s;=&"',]+=[^\s;&"',]*(?:;\s*[^\s;=&"',]+=[^\s;&"',]*)*/;
|
|
555
|
+
let HEADER_TOKEN_PATTERN = /^[^\s&"',;]+/;
|
|
556
|
+
// A scheme word (`Bearer`, `OAuth`, `Bot`, `SSWS`, `Zoho-oauthtoken`, `Klaviyo-API-Key`)
|
|
557
|
+
// followed by its credential.
|
|
558
|
+
let HEADER_SCHEME_PATTERN = /^([A-Za-z][\w-]*)(\s+)([\s\S]*)$/;
|
|
559
|
+
// `Digest username="x", response="..."`, `AWS4-HMAC-SHA256 Credential=..., Signature=...`.
|
|
560
|
+
let HEADER_AUTH_PARAMS_PATTERN = /^[A-Za-z][\w-]*\s*=/;
|
|
561
|
+
|
|
562
|
+
// Header values are redacted only when they hold a credential, so prose such as
|
|
563
|
+
// `Authorization: missing or invalid` or `Missing cookie: please log in` stays readable.
|
|
564
|
+
let redactCredentialHeaderValue = (name: string, value: string) => {
|
|
565
|
+
if (COOKIE_HEADER_NAME_PATTERN.test(name)) {
|
|
566
|
+
let pairs = COOKIE_PAIRS_PATTERN.exec(value)?.[0];
|
|
567
|
+
if (pairs) return `${REDACTED_VALUE}${value.slice(pairs.length)}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let scheme = HEADER_SCHEME_PATTERN.exec(value);
|
|
571
|
+
if (scheme) {
|
|
572
|
+
let [, word = '', space = '', rest = ''] = scheme;
|
|
573
|
+
let schemePrefix = `${word}${space}`;
|
|
574
|
+
if (
|
|
575
|
+
AUTHORIZATION_HEADER_NAME_PATTERN.test(name) &&
|
|
576
|
+
HEADER_AUTH_PARAMS_PATTERN.test(rest)
|
|
577
|
+
) {
|
|
578
|
+
// Auth-param lists carry the credential in any parameter, so all of them go.
|
|
579
|
+
return `${schemePrefix}${REDACTED_VALUE}`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let credential = HEADER_TOKEN_PATTERN.exec(rest)?.[0];
|
|
583
|
+
if (credential === REDACTED_VALUE) return value;
|
|
584
|
+
if (
|
|
585
|
+
credential &&
|
|
586
|
+
(AUTH_SCHEME_WORD_PATTERN.test(word) || looksLikeCredential(credential))
|
|
587
|
+
) {
|
|
588
|
+
return `${schemePrefix}${REDACTED_VALUE}${rest.slice(credential.length)}`;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// No scheme: the first token is the credential itself (`X-API-Key: k1`).
|
|
593
|
+
let token = HEADER_TOKEN_PATTERN.exec(value)?.[0];
|
|
594
|
+
if (token && token !== REDACTED_VALUE && looksLikeCredential(token)) {
|
|
595
|
+
return `${REDACTED_VALUE}${value.slice(token.length)}`;
|
|
596
|
+
}
|
|
597
|
+
return value;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// Generic `'key': 'value'` scan (Python reprs, single-quoted logs); the key is filtered
|
|
601
|
+
// via isSecretKeyName at replace time.
|
|
602
|
+
let GENERIC_SINGLE_QUOTED_KEY_VALUE_PATTERN =
|
|
603
|
+
/'([^'\\]*(?:\\.[^'\\]*)*)'(\s*:\s*)'([^'\\]*(?:\\.[^'\\]*)*)'/g;
|
|
604
|
+
|
|
605
|
+
// Unquoted `key: value` in prose and JS-style object dumps: `password: Hunter2`,
|
|
606
|
+
// `Invalid token: ya29...`, `{ access_token: 'abc' }`. The key is the word right before
|
|
607
|
+
// the colon; the value is one token, optionally quoted.
|
|
608
|
+
let PROSE_KEY_VALUE_PATTERN =
|
|
609
|
+
/(?<![A-Za-z0-9_\-.])([A-Za-z][A-Za-z0-9_\-.]*)([ \t]*:[ \t]*)(["'`]?)([^\s"'`,;&)\]}]+)/g;
|
|
610
|
+
|
|
611
|
+
// Words that describe a credential's state rather than being one (`token: expired`).
|
|
612
|
+
let PROSE_STATE_WORD_PATTERN =
|
|
613
|
+
/^(?:required|missing|invalid|expired|revoked|empty|blank|null|undefined|none|not|is|was|has|must|should|cannot|incorrect|wrong|malformed|provided|present|absent|unknown|denied|rejected|unauthorized|forbidden|true|false|too|the|a|an)$/i;
|
|
614
|
+
|
|
615
|
+
// Lowercase snake_case words are error codes (`token: invalid_grant`), not credentials.
|
|
616
|
+
let PROSE_ERROR_CODE_PATTERN = /^[a-z]+(?:_[a-z]+)+$/;
|
|
617
|
+
|
|
618
|
+
let redactProseKeyValue = (
|
|
619
|
+
match: string,
|
|
620
|
+
key: string,
|
|
621
|
+
separator: string,
|
|
622
|
+
quote: string,
|
|
623
|
+
value: string
|
|
624
|
+
) => {
|
|
625
|
+
if (AUTHORIZATION_HEADER_NAME_PATTERN.test(key) || COOKIE_HEADER_NAME_PATTERN.test(key)) {
|
|
626
|
+
return match;
|
|
627
|
+
}
|
|
628
|
+
if (value.startsWith('[') || value.startsWith(AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX)) {
|
|
629
|
+
return match;
|
|
630
|
+
}
|
|
631
|
+
if (!isSecretFieldName(key)) return match;
|
|
632
|
+
|
|
633
|
+
// Sentence punctuation after the value (`token: expired.`) is not part of it.
|
|
634
|
+
let credential = value.replace(/[.!?:]+$/, '');
|
|
635
|
+
if (!credential || PROSE_ERROR_CODE_PATTERN.test(credential)) return match;
|
|
636
|
+
|
|
637
|
+
// A single-word key (`token`, `password`) needs a credential-shaped value; a compound
|
|
638
|
+
// key (`access_token`, `sessionToken`) is specific enough that any non-state word is
|
|
639
|
+
// treated as its value.
|
|
640
|
+
let compoundKey = splitKeyParts(key).length > 1;
|
|
641
|
+
if (
|
|
642
|
+
looksLikeCredential(credential) ||
|
|
643
|
+
(compoundKey && !PROSE_STATE_WORD_PATTERN.test(credential))
|
|
644
|
+
) {
|
|
645
|
+
return `${key}${separator}${quote}${REDACTED_VALUE}${value.slice(credential.length)}`;
|
|
646
|
+
}
|
|
647
|
+
return match;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// Narrower than sanitizeFreeText for human-readable error text: redacts known secret
|
|
651
|
+
// formats, scheme credentials, credential header values, and secret-named
|
|
652
|
+
// `key=value` / `key: value` / `"key": "value"` / `'key': 'value'` pairs, but never a
|
|
653
|
+
// bare `code=` value or a prose word that merely follows "token"/"basic".
|
|
654
|
+
export let sanitizeSecretValuesInText = (text: string) => {
|
|
655
|
+
if (!text) return text;
|
|
656
|
+
|
|
657
|
+
let result = text.replace(
|
|
658
|
+
PROSE_SAFE_AUTH_SCHEME_PATTERN,
|
|
659
|
+
(match, scheme: string, space: string, value: string) =>
|
|
660
|
+
looksLikeCredential(value) ? `${scheme}${space}${REDACTED_VALUE}` : match
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
result = result.replace(
|
|
664
|
+
CREDENTIAL_HEADER_VALUE_PATTERN,
|
|
665
|
+
(
|
|
666
|
+
_match,
|
|
667
|
+
name: string,
|
|
668
|
+
separator: string,
|
|
669
|
+
quote: string | undefined,
|
|
670
|
+
quotedValue: string | undefined,
|
|
671
|
+
plainValue: string | undefined
|
|
672
|
+
) =>
|
|
673
|
+
`${name}${separator}${quote ?? ''}${redactCredentialHeaderValue(
|
|
674
|
+
name,
|
|
675
|
+
quote ? (quotedValue ?? '') : (plainValue ?? '')
|
|
676
|
+
)}`
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
result = redactKnownSecretValues(result);
|
|
680
|
+
|
|
681
|
+
result = result.replace(
|
|
682
|
+
GENERIC_KEY_VALUE_PATTERN,
|
|
683
|
+
(match, key: string, equals: string, value: string, offset: number, whole: string) => {
|
|
684
|
+
if (!isSecretKeyName(key)) return match;
|
|
685
|
+
// `Authorization=Basic [redacted]` was already handled by the scheme patterns.
|
|
686
|
+
let rest = whole.slice(offset + match.length);
|
|
687
|
+
if (AUTH_SCHEME_WORD_PATTERN.test(value) && /^\s+\[redacted\]/.test(rest)) return match;
|
|
688
|
+
return `${key}${equals}${REDACTED_VALUE}`;
|
|
689
|
+
}
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
result = result.replace(PROSE_KEY_VALUE_PATTERN, redactProseKeyValue);
|
|
693
|
+
|
|
694
|
+
result = result.replace(
|
|
695
|
+
GENERIC_SINGLE_QUOTED_KEY_VALUE_PATTERN,
|
|
696
|
+
(match, key: string, colon: string, _value: string) =>
|
|
697
|
+
isSecretKeyName(key) ? `'${key}'${colon}'${REDACTED_VALUE}'` : match
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
return result.replace(
|
|
701
|
+
GENERIC_JSON_KEY_VALUE_PATTERN,
|
|
702
|
+
(match, key: string, colon: string, _value: string) =>
|
|
703
|
+
isSecretKeyName(key) ? `"${key}"${colon}"${REDACTED_VALUE}"` : match
|
|
704
|
+
);
|
|
705
|
+
};
|
|
706
|
+
|
|
412
707
|
let sanitizeScalar = (value: string, redactor?: AuthConfigSecretRedactor) =>
|
|
413
708
|
truncateText(sanitizeFreeText(value, redactor));
|
|
414
709
|
|
|
@@ -549,6 +844,13 @@ let decodeRedactedMarker = (value: string) =>
|
|
|
549
844
|
? value
|
|
550
845
|
: value.split(REDACTED_ENCODED).join(REDACTED_VALUE);
|
|
551
846
|
|
|
847
|
+
// A query/fragment value under a non-secret name: a literal secret-named auth-config
|
|
848
|
+
// value or a known credential format (`?q=sk_live_...`) is still replaced.
|
|
849
|
+
let redactQueryValue = (rawValue: string, redactor?: AuthConfigSecretRedactor) =>
|
|
850
|
+
redactKnownSecretValues(
|
|
851
|
+
redactor ? redactor.redact(rawValue, isAuthConfigSecretValue) : rawValue
|
|
852
|
+
);
|
|
853
|
+
|
|
552
854
|
let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) => {
|
|
553
855
|
if (!hash?.includes('=')) return hash;
|
|
554
856
|
let withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
@@ -556,14 +858,25 @@ let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) =>
|
|
|
556
858
|
/(?<![A-Za-z0-9])([A-Za-z0-9][A-Za-z0-9_\-.]*)=([^&]*)/g,
|
|
557
859
|
(match, key: string, rawValue: string) => {
|
|
558
860
|
if (isUrlFormSecretKeyName(key)) return `${key}=${REDACTED_VALUE}`;
|
|
559
|
-
let
|
|
560
|
-
return
|
|
861
|
+
let redactedValue = redactQueryValue(rawValue, redactor);
|
|
862
|
+
return redactedValue !== rawValue ? `${key}=${redactedValue}` : match;
|
|
561
863
|
}
|
|
562
864
|
);
|
|
563
865
|
return redacted ? `#${redacted}` : '';
|
|
564
866
|
};
|
|
565
867
|
|
|
566
|
-
|
|
868
|
+
// Redacts secret-keyed auth values embedded in URL paths (e.g. Telegram's `/bot<token>/...`).
|
|
869
|
+
let isEmbeddedPathSecret = (secret: string, path: string) =>
|
|
870
|
+
secret.length >= 8 && isAuthConfigSecretValue(secret, path);
|
|
871
|
+
|
|
872
|
+
// Replaces secret-named auth-config values in free text: whole-value matches, plus
|
|
873
|
+
// values of 8+ chars embedded in longer text such as URL paths or error messages.
|
|
874
|
+
export let redactAuthConfigSecretsInText = (
|
|
875
|
+
text: string,
|
|
876
|
+
redactor: AuthConfigSecretRedactor
|
|
877
|
+
): string => redactor.redactEmbedded(text, isEmbeddedPathSecret, isAuthConfigSecretValue);
|
|
878
|
+
|
|
879
|
+
export let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
|
|
567
880
|
if (!value) return value;
|
|
568
881
|
|
|
569
882
|
try {
|
|
@@ -574,27 +887,32 @@ let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
|
|
|
574
887
|
url.password = '';
|
|
575
888
|
}
|
|
576
889
|
|
|
890
|
+
if (redactor) url.pathname = redactAuthConfigSecretsInText(url.pathname, redactor);
|
|
891
|
+
|
|
577
892
|
for (let key of Array.from(url.searchParams.keys())) {
|
|
578
893
|
if (isUrlFormSecretKeyName(key)) {
|
|
579
894
|
url.searchParams.set(key, REDACTED_VALUE);
|
|
580
895
|
continue;
|
|
581
896
|
}
|
|
582
897
|
|
|
583
|
-
// Not a secret-looking key -- still catch a literal auth-config secret value
|
|
898
|
+
// Not a secret-looking key -- still catch a literal auth-config secret value or a
|
|
899
|
+
// known credential format.
|
|
584
900
|
let rawValue = url.searchParams.get(key) ?? '';
|
|
585
|
-
let
|
|
586
|
-
if (
|
|
901
|
+
let redactedValue = redactQueryValue(rawValue, redactor);
|
|
902
|
+
if (redactedValue !== rawValue) url.searchParams.set(key, redactedValue);
|
|
587
903
|
}
|
|
588
904
|
|
|
589
905
|
url.hash = redactHashFragment(url.hash, redactor);
|
|
590
906
|
|
|
591
907
|
return decodeRedactedMarker(url.toString());
|
|
592
908
|
} catch {
|
|
593
|
-
let text = redactor ?
|
|
909
|
+
let text = redactor ? redactAuthConfigSecretsInText(value, redactor) : value;
|
|
594
910
|
return text.replace(
|
|
595
911
|
/([?&]([^=&#]+)=)([^&#]+)/g,
|
|
596
912
|
(_match, prefix: string, key: string, rawValue: string) =>
|
|
597
|
-
isUrlFormSecretKeyName(key)
|
|
913
|
+
isUrlFormSecretKeyName(key)
|
|
914
|
+
? `${prefix}${REDACTED_VALUE}`
|
|
915
|
+
: `${prefix}${redactKnownSecretValues(rawValue)}`
|
|
598
916
|
);
|
|
599
917
|
}
|
|
600
918
|
};
|
|
@@ -796,6 +1114,8 @@ export let __traceInternals = {
|
|
|
796
1114
|
sanitizeHeaders,
|
|
797
1115
|
isSafeHeaderName,
|
|
798
1116
|
isSecretKeyName,
|
|
1117
|
+
isSecretFieldName,
|
|
1118
|
+
isAuthConfigSecretValue,
|
|
799
1119
|
isUrlFormSecretKeyName,
|
|
800
1120
|
redactStructuredValue,
|
|
801
1121
|
SECRET_KEY_PATTERN,
|
package/src/error/api.test.ts
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
import { ServiceError } from '@lowerdeck/error';
|
|
2
|
+
import { AxiosError, AxiosHeaders } from 'axios';
|
|
2
3
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildApiServiceError,
|
|
6
|
+
collectApiErrorDetails,
|
|
7
|
+
createApiServiceError,
|
|
8
|
+
decodeApiErrorBody,
|
|
9
|
+
getApiErrorResponseWithBody,
|
|
10
|
+
looksLikeHtmlErrorBody,
|
|
11
|
+
pushBoundedApiErrorDetail,
|
|
12
|
+
readApiErrorText,
|
|
13
|
+
truncateApiErrorMessage
|
|
14
|
+
} from './api';
|
|
4
15
|
import { SlateError } from './base';
|
|
5
16
|
|
|
17
|
+
let axiosFailure = (status: number, data: unknown) => {
|
|
18
|
+
let config = { url: '/widgets', method: 'get', headers: new AxiosHeaders() };
|
|
19
|
+
return new AxiosError('Request failed', 'ERR_BAD_REQUEST', config, undefined, {
|
|
20
|
+
status,
|
|
21
|
+
statusText: 'Error',
|
|
22
|
+
data,
|
|
23
|
+
headers: {},
|
|
24
|
+
config
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
6
28
|
describe('API service error helpers', () => {
|
|
7
29
|
it('collects unique nested API error details', () => {
|
|
8
30
|
let details: string[] = [];
|
|
@@ -111,4 +133,104 @@ describe('API service error helpers', () => {
|
|
|
111
133
|
})
|
|
112
134
|
).toBe(existing);
|
|
113
135
|
});
|
|
136
|
+
|
|
137
|
+
it('recovers the upstream body from SlateError baggage', () => {
|
|
138
|
+
let parent = SlateError.fromAxios(
|
|
139
|
+
axiosFailure(422, { errors: [{ message: 'Name is required' }] })
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(parent.name).toBe('SlateError');
|
|
143
|
+
expect((parent as any).response).toBeUndefined();
|
|
144
|
+
expect(getApiErrorResponseWithBody(parent)).toEqual({
|
|
145
|
+
status: 422,
|
|
146
|
+
data: { errors: [{ message: 'Name is required' }] }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let error = buildApiServiceError(parent, {
|
|
150
|
+
providerLabel: 'Example',
|
|
151
|
+
reason: 'example_api_error',
|
|
152
|
+
extractResponse: (input, helpers) => helpers.getResponseWithBody(input)
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(error.data.upstreamStatus).toBe(422);
|
|
156
|
+
expect(error.data.message).toBe('Example API request failed: HTTP 422: Name is required');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('prefers an existing response body and ignores non-SlateErrors without one', () => {
|
|
160
|
+
let response = { status: 400, data: { message: 'Direct' } };
|
|
161
|
+
|
|
162
|
+
expect(getApiErrorResponseWithBody({ response })).toEqual({
|
|
163
|
+
status: 400,
|
|
164
|
+
statusText: undefined,
|
|
165
|
+
data: { message: 'Direct' }
|
|
166
|
+
});
|
|
167
|
+
expect(
|
|
168
|
+
getApiErrorResponseWithBody({ name: 'Error', data: { baggage: { response: 'x' } } })
|
|
169
|
+
).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('detects HTML error pages without dropping plain text that starts with <', () => {
|
|
173
|
+
expect(looksLikeHtmlErrorBody('<!DOCTYPE html><html><body>Bad</body></html>')).toBe(true);
|
|
174
|
+
expect(looksLikeHtmlErrorBody(' <html lang="en">')).toBe(true);
|
|
175
|
+
expect(looksLikeHtmlErrorBody('<h1>502 Bad Gateway</h1>')).toBe(true);
|
|
176
|
+
expect(looksLikeHtmlErrorBody('Error page: <body>oops</body>')).toBe(true);
|
|
177
|
+
expect(looksLikeHtmlErrorBody('<id> is required')).toBe(false);
|
|
178
|
+
expect(looksLikeHtmlErrorBody('<body> is required')).toBe(false);
|
|
179
|
+
expect(looksLikeHtmlErrorBody('Invalid <body> parameter')).toBe(false);
|
|
180
|
+
expect(looksLikeHtmlErrorBody('<title> is required')).toBe(false);
|
|
181
|
+
expect(looksLikeHtmlErrorBody('<p> must be a string')).toBe(false);
|
|
182
|
+
expect(looksLikeHtmlErrorBody('<= 100 items allowed')).toBe(false);
|
|
183
|
+
expect(looksLikeHtmlErrorBody('Field must be a number')).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reads displayable error text and keeps detail lists bounded and unique', () => {
|
|
187
|
+
expect(readApiErrorText(' Bad input ')).toBe('Bad input');
|
|
188
|
+
expect(readApiErrorText(42)).toBe('42');
|
|
189
|
+
expect(readApiErrorText(42, { includeNumbers: false })).toBeUndefined();
|
|
190
|
+
expect(readApiErrorText('[truncated]')).toBeUndefined();
|
|
191
|
+
expect(readApiErrorText(' [redacted] ')).toBeUndefined();
|
|
192
|
+
expect(readApiErrorText('[unserializable]')).toBeUndefined();
|
|
193
|
+
expect(readApiErrorText('Token [redacted] is invalid')).toBe(
|
|
194
|
+
'Token [redacted] is invalid'
|
|
195
|
+
);
|
|
196
|
+
expect(readApiErrorText('<html></html>')).toBeUndefined();
|
|
197
|
+
expect(readApiErrorText({ message: 'x' })).toBeUndefined();
|
|
198
|
+
|
|
199
|
+
let details: string[] = [];
|
|
200
|
+
for (let value of ['a', 'a', '', '[truncated]', 'b', 'c', 'd', 'e', 'f']) {
|
|
201
|
+
pushBoundedApiErrorDetail(details, value);
|
|
202
|
+
}
|
|
203
|
+
expect(details).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
204
|
+
|
|
205
|
+
let fieldDetails: string[] = [];
|
|
206
|
+
pushBoundedApiErrorDetail(fieldDetails, 'is required', { field: 'name' });
|
|
207
|
+
pushBoundedApiErrorDetail(fieldDetails, 'is required', { field: 'name' });
|
|
208
|
+
pushBoundedApiErrorDetail(fieldDetails, 'second', { maxDetails: 1 });
|
|
209
|
+
expect(fieldDetails).toEqual(['name: is required']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('truncates long error messages', () => {
|
|
213
|
+
expect(truncateApiErrorMessage('abc', 5)).toBe('abc');
|
|
214
|
+
expect(truncateApiErrorMessage('abcdef', 3)).toBe('abc...');
|
|
215
|
+
expect(truncateApiErrorMessage('x'.repeat(1_001))).toBe(`${'x'.repeat(1_000)}...`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('decodes text and binary error bodies', () => {
|
|
219
|
+
let json = '{"error":{"message":"Not found"}}';
|
|
220
|
+
let bytes = new TextEncoder().encode(json);
|
|
221
|
+
|
|
222
|
+
expect(decodeApiErrorBody(json)).toEqual({ error: { message: 'Not found' } });
|
|
223
|
+
expect(decodeApiErrorBody(bytes.buffer)).toEqual({ error: { message: 'Not found' } });
|
|
224
|
+
expect(decodeApiErrorBody(bytes)).toEqual({ error: { message: 'Not found' } });
|
|
225
|
+
expect(decodeApiErrorBody(Buffer.from(` ${json} `))).toEqual({
|
|
226
|
+
error: { message: 'Not found' }
|
|
227
|
+
});
|
|
228
|
+
expect(decodeApiErrorBody(' plain text ')).toBe('plain text');
|
|
229
|
+
expect(decodeApiErrorBody('{not json')).toBe('{not json');
|
|
230
|
+
expect(decodeApiErrorBody(new TextEncoder().encode('abcdef'), { maxBytes: 3 })).toBe(
|
|
231
|
+
'abc'
|
|
232
|
+
);
|
|
233
|
+
expect(decodeApiErrorBody({ already: 'parsed' })).toEqual({ already: 'parsed' });
|
|
234
|
+
expect(decodeApiErrorBody(undefined)).toBeUndefined();
|
|
235
|
+
});
|
|
114
236
|
});
|