@slates/provider 1.0.0-rc.18 → 1.0.0-rc.19
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 +328 -41
- package/dist/index.d.cts +113 -22
- package/dist/index.d.ts +113 -22
- package/dist/index.module.js +320 -41
- package/package.json +2 -1
- package/src/action/attachment.ts +55 -9
- package/src/action/directUpload.ts +59 -0
- package/src/action/getFileUrlTool.ts +41 -0
- package/src/action/index.ts +1 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/redact.test.ts +103 -0
- package/src/auth/redact.ts +111 -0
- package/src/auth/types.ts +0 -1
- package/src/axios/trace.ts +69 -35
- package/src/context/context.ts +193 -3
- package/src/index.ts +1 -0
package/src/action/attachment.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export interface SlateAttachmentUrlContent {
|
|
2
2
|
type: 'url';
|
|
3
3
|
url: string;
|
|
4
|
+
headers?: Record<string, string>;
|
|
5
|
+
query?: Record<string, string>;
|
|
6
|
+
refreshReference?: unknown;
|
|
7
|
+
refreshAt?: string;
|
|
4
8
|
}
|
|
5
9
|
|
|
6
10
|
export interface SlateAttachmentInlineContent {
|
|
@@ -9,21 +13,58 @@ export interface SlateAttachmentInlineContent {
|
|
|
9
13
|
content: string;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export interface SlateAttachmentUploadReferenceContent {
|
|
17
|
+
type: 'upload_reference';
|
|
18
|
+
referenceId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export interface SlateAttachment {
|
|
13
22
|
mimeType?: string;
|
|
14
|
-
|
|
23
|
+
attachmentHash?: string;
|
|
24
|
+
content:
|
|
25
|
+
| SlateAttachmentUrlContent
|
|
26
|
+
| SlateAttachmentInlineContent
|
|
27
|
+
| SlateAttachmentUploadReferenceContent;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
|
-
export let createUrlAttachment = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
export let createUrlAttachment = (
|
|
31
|
+
url: string,
|
|
32
|
+
opts: {
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
attachmentHash?: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
query?: Record<string, string>;
|
|
37
|
+
refreshReference?: unknown;
|
|
38
|
+
refreshAt?: string;
|
|
39
|
+
} = {}
|
|
40
|
+
): SlateAttachment => {
|
|
41
|
+
if (opts.refreshReference !== undefined && !opts.refreshAt) {
|
|
42
|
+
throw new Error('createUrlAttachment: refreshAt is required when refreshReference is set');
|
|
22
43
|
}
|
|
23
|
-
});
|
|
24
44
|
|
|
25
|
-
|
|
45
|
+
return {
|
|
46
|
+
mimeType: opts.mimeType,
|
|
47
|
+
attachmentHash: opts.attachmentHash,
|
|
48
|
+
content: {
|
|
49
|
+
type: 'url',
|
|
50
|
+
url,
|
|
51
|
+
...(opts.headers ? { headers: opts.headers } : {}),
|
|
52
|
+
...(opts.query ? { query: opts.query } : {}),
|
|
53
|
+
...(opts.refreshReference !== undefined
|
|
54
|
+
? { refreshReference: opts.refreshReference }
|
|
55
|
+
: {}),
|
|
56
|
+
...(opts.refreshAt ? { refreshAt: opts.refreshAt } : {})
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export let createBase64Attachment = (
|
|
62
|
+
content: string,
|
|
63
|
+
mimeType?: string,
|
|
64
|
+
attachmentHash?: string
|
|
65
|
+
): SlateAttachment => ({
|
|
26
66
|
mimeType,
|
|
67
|
+
attachmentHash,
|
|
27
68
|
content: {
|
|
28
69
|
type: 'content',
|
|
29
70
|
encoding: 'base64',
|
|
@@ -31,8 +72,13 @@ export let createBase64Attachment = (content: string, mimeType?: string): SlateA
|
|
|
31
72
|
}
|
|
32
73
|
});
|
|
33
74
|
|
|
34
|
-
export let createTextAttachment = (
|
|
75
|
+
export let createTextAttachment = (
|
|
76
|
+
content: string,
|
|
77
|
+
mimeType?: string,
|
|
78
|
+
attachmentHash?: string
|
|
79
|
+
): SlateAttachment => ({
|
|
35
80
|
mimeType,
|
|
81
|
+
attachmentHash,
|
|
36
82
|
content: {
|
|
37
83
|
type: 'content',
|
|
38
84
|
encoding: 'utf-8',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SlateAttachment } from './attachment';
|
|
2
|
+
|
|
3
|
+
export interface SlateLiveInvocationInfo {
|
|
4
|
+
token: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
maxAttachmentSizeBytes: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SlateDirectUploadInput {
|
|
10
|
+
live: SlateLiveInvocationInfo;
|
|
11
|
+
mimeType?: string;
|
|
12
|
+
filename?: string;
|
|
13
|
+
body: ReadableStream<Uint8Array> | Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export let uploadAttachmentDirect = async (
|
|
17
|
+
d: SlateDirectUploadInput
|
|
18
|
+
): Promise<SlateAttachment> => {
|
|
19
|
+
let baseUrl = d.live.baseUrl.replace(/\/$/, '');
|
|
20
|
+
|
|
21
|
+
let registerRes = await fetch(`${baseUrl}/slates-hub/live-invocation/attachments`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: {
|
|
24
|
+
'content-type': 'application/json',
|
|
25
|
+
authorization: `Bearer ${d.live.token}`
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
attachments: [{ mimeType: d.mimeType, filename: d.filename }]
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
if (!registerRes.ok) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Failed to register attachment for direct upload (status ${registerRes.status})`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let { attachments } = (await registerRes.json()) as {
|
|
38
|
+
attachments: { referenceId: string; uploadUrl: string }[];
|
|
39
|
+
};
|
|
40
|
+
let registration = attachments[0];
|
|
41
|
+
if (!registration) {
|
|
42
|
+
throw new Error('Live invocation API returned no attachment registrations');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let putRes = await fetch(registration.uploadUrl, {
|
|
46
|
+
method: 'PUT',
|
|
47
|
+
...(d.body instanceof Uint8Array ? {} : ({ duplex: 'half' } as any)),
|
|
48
|
+
headers: d.mimeType ? { 'content-type': d.mimeType } : undefined,
|
|
49
|
+
body: d.body
|
|
50
|
+
});
|
|
51
|
+
if (!putRes.ok) {
|
|
52
|
+
throw new Error(`Failed to upload attachment content (status ${putRes.status})`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
mimeType: d.mimeType,
|
|
57
|
+
content: { type: 'upload_reference', referenceId: registration.referenceId }
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { SlateContext } from '../context/context';
|
|
3
|
+
import type { SlateSpecification } from '../specification/specification';
|
|
4
|
+
import { tool } from './tool';
|
|
5
|
+
|
|
6
|
+
export let GET_FILE_URL_TOOL_ID = 'metorial$getFileUrl';
|
|
7
|
+
|
|
8
|
+
export let getFileUrlToolInputSchema = z.object({
|
|
9
|
+
url: z.string(),
|
|
10
|
+
reference: z.any().nullable()
|
|
11
|
+
});
|
|
12
|
+
export type GetFileUrlToolInput = z.infer<typeof getFileUrlToolInputSchema>;
|
|
13
|
+
|
|
14
|
+
export let getFileUrlToolOutputSchema = z.object({
|
|
15
|
+
url: z.string(),
|
|
16
|
+
expiresAt: z.string(),
|
|
17
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
18
|
+
query: z.record(z.string(), z.string()).optional()
|
|
19
|
+
});
|
|
20
|
+
export type GetFileUrlToolOutput = z.infer<typeof getFileUrlToolOutputSchema>;
|
|
21
|
+
|
|
22
|
+
export type GetFileUrlHandler<ConfigType extends {}, AuthType extends {}> = (
|
|
23
|
+
context: SlateContext<ConfigType, AuthType, GetFileUrlToolInput>
|
|
24
|
+
) => Promise<GetFileUrlToolOutput>;
|
|
25
|
+
|
|
26
|
+
export let getFileUrlTool = <ConfigType extends {}, AuthType extends {}>(
|
|
27
|
+
spec: SlateSpecification<ConfigType, AuthType>,
|
|
28
|
+
handleGetFileUrl: GetFileUrlHandler<ConfigType, AuthType>
|
|
29
|
+
) =>
|
|
30
|
+
tool(spec, {
|
|
31
|
+
key: GET_FILE_URL_TOOL_ID,
|
|
32
|
+
name: 'Get File URL',
|
|
33
|
+
description: 'Reserved: refreshes an expiring attachment download URL. Hub-internal only.'
|
|
34
|
+
})
|
|
35
|
+
.input(getFileUrlToolInputSchema)
|
|
36
|
+
.output(getFileUrlToolOutputSchema)
|
|
37
|
+
.handleInvocation(async ctx => ({
|
|
38
|
+
output: await handleGetFileUrl(ctx),
|
|
39
|
+
message: 'ok'
|
|
40
|
+
}))
|
|
41
|
+
.build();
|
package/src/action/index.ts
CHANGED
package/src/auth/index.ts
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { SlateAttachment } from '../action/attachment';
|
|
3
|
+
import { AuthConfigSecretRedactor, redactUrlAttachmentSecrets } from './redact';
|
|
4
|
+
|
|
5
|
+
describe('AuthConfigSecretRedactor', () => {
|
|
6
|
+
let authConfig = { token: '123SECRET', nested: { token2: 'also_secret' } };
|
|
7
|
+
|
|
8
|
+
it('redacts values that match a secret to placeholders', () => {
|
|
9
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
10
|
+
|
|
11
|
+
expect(
|
|
12
|
+
redactor.redact({
|
|
13
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
14
|
+
query: { key: 'also_secret' }
|
|
15
|
+
})
|
|
16
|
+
).toEqual({
|
|
17
|
+
headers: { Authorization: 'Bearer 123SECRET' }, // whole-value match only, not substring
|
|
18
|
+
query: { key: '$$MT$secret$authConfig$nested.token2' }
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('redacts a bare string value', () => {
|
|
23
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
24
|
+
expect(redactor.redact('123SECRET')).toBe('$$MT$secret$authConfig$token');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('leaves non-secret strings untouched', () => {
|
|
28
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
29
|
+
expect(redactor.redact('https://example.com/file.pdf')).toBe(
|
|
30
|
+
'https://example.com/file.pdf'
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('recurses through arrays and nested objects', () => {
|
|
35
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
36
|
+
expect(redactor.redact({ list: ['also_secret', 'plain'] })).toEqual({
|
|
37
|
+
list: ['$$MT$secret$authConfig$nested.token2', 'plain']
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never registers an empty-string secret', () => {
|
|
42
|
+
let redactor = new AuthConfigSecretRedactor({ token: '' });
|
|
43
|
+
expect(redactor.redact({ a: '', b: '' })).toEqual({ a: '', b: '' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('is a no-op when there is no auth config', () => {
|
|
47
|
+
let redactor = new AuthConfigSecretRedactor(undefined);
|
|
48
|
+
expect(redactor.redact({ a: 'b' })).toEqual({ a: 'b' });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('redactUrlAttachmentSecrets', () => {
|
|
53
|
+
let authConfig = { token: '123SECRET', nested: { token2: 'also_secret' } };
|
|
54
|
+
|
|
55
|
+
let urlAttachment = (
|
|
56
|
+
overrides: Partial<Extract<SlateAttachment['content'], { type: 'url' }>> = {}
|
|
57
|
+
): SlateAttachment => ({
|
|
58
|
+
mimeType: 'application/pdf',
|
|
59
|
+
content: { type: 'url', url: 'https://example.com/file.pdf', ...overrides }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('redacts headers and query on url attachments', () => {
|
|
63
|
+
let attachments: SlateAttachment[] = [
|
|
64
|
+
urlAttachment({
|
|
65
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
66
|
+
query: { key: 'also_secret' }
|
|
67
|
+
})
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual([
|
|
71
|
+
urlAttachment({
|
|
72
|
+
headers: { Authorization: 'Bearer 123SECRET' },
|
|
73
|
+
query: { key: '$$MT$secret$authConfig$nested.token2' }
|
|
74
|
+
})
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves url attachments with no headers/query untouched', () => {
|
|
79
|
+
let attachments = [urlAttachment()];
|
|
80
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual(attachments);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves content and upload_reference attachments untouched', () => {
|
|
84
|
+
let attachments: SlateAttachment[] = [
|
|
85
|
+
{
|
|
86
|
+
mimeType: 'text/plain',
|
|
87
|
+
content: { type: 'content', encoding: 'utf-8', content: 'hi' }
|
|
88
|
+
},
|
|
89
|
+
{ content: { type: 'upload_reference', referenceId: 'ref_1' } }
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
expect(redactUrlAttachmentSecrets(attachments, authConfig)).toEqual(attachments);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is a no-op when there is no auth config', () => {
|
|
96
|
+
let attachments = [urlAttachment({ headers: { Authorization: 'Bearer 123SECRET' } })];
|
|
97
|
+
expect(redactUrlAttachmentSecrets(attachments, undefined)).toBe(attachments);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('passes an undefined attachment list through unchanged', () => {
|
|
101
|
+
expect(redactUrlAttachmentSecrets(undefined, authConfig)).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { SlateAttachment } from '../action/attachment';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Placeholder prefix used to mark a redacted auth-config secret. Must match the hub's
|
|
5
|
+
* AuthConfigSecretSerializer (oss/src/slates/apps/hub/src/lib/secretSerializer.ts) exactly --
|
|
6
|
+
* the hub resolves these placeholders back to live secret values when it proxies an attachment.
|
|
7
|
+
*/
|
|
8
|
+
export let AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX = '$$MT$secret$authConfig$';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Walks an auth config object and replaces any string value that exactly matches one of its
|
|
12
|
+
* leaf values with a `$$MT$secret$authConfig$<path>` placeholder. Literal, whole-value
|
|
13
|
+
* substitution only -- not a substring scan, and not a heuristic key-name/value-shape guess
|
|
14
|
+
* like the http trace redactor in '../axios/trace'.
|
|
15
|
+
*/
|
|
16
|
+
export class AuthConfigSecretRedactor {
|
|
17
|
+
private readonly secretToPlaceholder = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
public constructor(authConfig: Record<string, unknown> | undefined) {
|
|
20
|
+
if (authConfig) this.buildSecretMap(authConfig);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public redact<T>(value: T): T {
|
|
24
|
+
return this.transform(value, stringValue => {
|
|
25
|
+
return this.secretToPlaceholder.get(stringValue) ?? stringValue;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private buildSecretMap(value: unknown, path: string[] = []): void {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
// Never register "" -- it would replace every empty header/query value with a placeholder.
|
|
32
|
+
if (value.length === 0) return;
|
|
33
|
+
|
|
34
|
+
this.secretToPlaceholder.set(
|
|
35
|
+
value,
|
|
36
|
+
`${AUTH_CONFIG_SECRET_PLACEHOLDER_PREFIX}${path.join('.')}`
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
value.forEach((item, index) => {
|
|
43
|
+
this.buildSecretMap(item, [...path, String(index)]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (this.isPlainObject(value)) {
|
|
50
|
+
Object.entries(value).forEach(([key, nestedValue]) => {
|
|
51
|
+
this.buildSecretMap(nestedValue, [...path, key]);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private transform<T>(value: T, transformString: (value: string) => string): T {
|
|
57
|
+
if (typeof value === 'string') {
|
|
58
|
+
return transformString(value) as T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return value.map(item => this.transform(item, transformString)) as T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.isPlainObject(value)) {
|
|
66
|
+
let result: Record<string, unknown> = {};
|
|
67
|
+
|
|
68
|
+
Object.entries(value).forEach(([key, nestedValue]) => {
|
|
69
|
+
result[key] = this.transform(nestedValue, transformString);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return result as T;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Applied to every finalized attachment list right before it's sent over
|
|
85
|
+
* `slates/action.tool.invoke` (see @slates/provider-handler). Only `type: 'url'` attachments
|
|
86
|
+
* carry headers/query -- content/upload_reference attachments pass through untouched.
|
|
87
|
+
*/
|
|
88
|
+
export let redactUrlAttachmentSecrets = (
|
|
89
|
+
attachments: SlateAttachment[] | undefined,
|
|
90
|
+
authConfig: Record<string, unknown> | undefined
|
|
91
|
+
): SlateAttachment[] | undefined => {
|
|
92
|
+
if (!authConfig || !attachments) return attachments;
|
|
93
|
+
|
|
94
|
+
let redactor = new AuthConfigSecretRedactor(authConfig);
|
|
95
|
+
|
|
96
|
+
return attachments.map(attachment => {
|
|
97
|
+
if (attachment.content.type !== 'url') return attachment;
|
|
98
|
+
if (!attachment.content.headers && !attachment.content.query) return attachment;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...attachment,
|
|
102
|
+
content: {
|
|
103
|
+
...attachment.content,
|
|
104
|
+
headers: attachment.content.headers
|
|
105
|
+
? redactor.redact(attachment.content.headers)
|
|
106
|
+
: undefined,
|
|
107
|
+
query: attachment.content.query ? redactor.redact(attachment.content.query) : undefined
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
};
|
package/src/auth/types.ts
CHANGED
package/src/axios/trace.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
|
2
|
+
import { AuthConfigSecretRedactor } from '../auth/redact';
|
|
2
3
|
import type { SlatePublicContext } from '../context';
|
|
3
4
|
|
|
4
5
|
export interface SlateHttpTraceTextBody {
|
|
@@ -377,10 +378,12 @@ let truncateText = (text: string) => {
|
|
|
377
378
|
};
|
|
378
379
|
};
|
|
379
380
|
|
|
380
|
-
let sanitizeFreeText = (text: string) => {
|
|
381
|
+
let sanitizeFreeText = (text: string, redactor?: AuthConfigSecretRedactor) => {
|
|
381
382
|
if (!text) return text;
|
|
382
383
|
|
|
383
|
-
|
|
384
|
+
// Literal auth-config secret values are replaced first -- this catches secrets sitting
|
|
385
|
+
// under a key name the heuristics below don't recognize as secret-like.
|
|
386
|
+
let result = redactor ? redactor.redact(text) : text;
|
|
384
387
|
|
|
385
388
|
result = result.replace(
|
|
386
389
|
HTTP_AUTH_SCHEME_PATTERN,
|
|
@@ -406,9 +409,14 @@ let sanitizeFreeText = (text: string) => {
|
|
|
406
409
|
return result;
|
|
407
410
|
};
|
|
408
411
|
|
|
409
|
-
let sanitizeScalar = (value: string) =>
|
|
412
|
+
let sanitizeScalar = (value: string, redactor?: AuthConfigSecretRedactor) =>
|
|
413
|
+
truncateText(sanitizeFreeText(value, redactor));
|
|
410
414
|
|
|
411
|
-
let redactStructuredValue = (
|
|
415
|
+
let redactStructuredValue = (
|
|
416
|
+
value: unknown,
|
|
417
|
+
depth = 0,
|
|
418
|
+
redactor?: AuthConfigSecretRedactor
|
|
419
|
+
): unknown => {
|
|
412
420
|
if (value == null) return value;
|
|
413
421
|
if (depth >= STRUCTURED_DEPTH_LIMIT) return '[truncated]';
|
|
414
422
|
|
|
@@ -418,13 +426,13 @@ let redactStructuredValue = (value: unknown, depth = 0): unknown => {
|
|
|
418
426
|
typeof value === 'boolean' ||
|
|
419
427
|
typeof value === 'bigint'
|
|
420
428
|
) {
|
|
421
|
-
return typeof value === 'string' ? sanitizeFreeText(value) : value;
|
|
429
|
+
return typeof value === 'string' ? sanitizeFreeText(value, redactor) : value;
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
if (Array.isArray(value)) {
|
|
425
433
|
return value
|
|
426
434
|
.slice(0, STRUCTURED_ENTRY_LIMIT)
|
|
427
|
-
.map(entry => redactStructuredValue(entry, depth + 1));
|
|
435
|
+
.map(entry => redactStructuredValue(entry, depth + 1, redactor));
|
|
428
436
|
}
|
|
429
437
|
|
|
430
438
|
if (isRecord(value)) {
|
|
@@ -433,7 +441,9 @@ let redactStructuredValue = (value: unknown, depth = 0): unknown => {
|
|
|
433
441
|
.slice(0, STRUCTURED_ENTRY_LIMIT)
|
|
434
442
|
.map(([key, entry]) => [
|
|
435
443
|
key,
|
|
436
|
-
isSecretKeyName(key)
|
|
444
|
+
isSecretKeyName(key)
|
|
445
|
+
? REDACTED_VALUE
|
|
446
|
+
: redactStructuredValue(entry, depth + 1, redactor)
|
|
437
447
|
])
|
|
438
448
|
);
|
|
439
449
|
}
|
|
@@ -457,7 +467,8 @@ let maybeParseJson = (text: string, contentType?: string) => {
|
|
|
457
467
|
|
|
458
468
|
let sanitizeTextBody = (
|
|
459
469
|
text: string,
|
|
460
|
-
contentType?: string
|
|
470
|
+
contentType?: string,
|
|
471
|
+
redactor?: AuthConfigSecretRedactor
|
|
461
472
|
): SlateHttpTraceTextBody | undefined => {
|
|
462
473
|
if (!text) return undefined;
|
|
463
474
|
|
|
@@ -465,8 +476,8 @@ let sanitizeTextBody = (
|
|
|
465
476
|
let parsedJson = maybeParseJson(text, normalizedContentType);
|
|
466
477
|
let normalizedText =
|
|
467
478
|
parsedJson !== null
|
|
468
|
-
? JSON.stringify(redactStructuredValue(parsedJson))
|
|
469
|
-
: sanitizeFreeText(text);
|
|
479
|
+
? JSON.stringify(redactStructuredValue(parsedJson, 0, redactor))
|
|
480
|
+
: sanitizeFreeText(text, redactor);
|
|
470
481
|
let { text: truncatedText, truncated } = truncateText(normalizedText);
|
|
471
482
|
|
|
472
483
|
return {
|
|
@@ -487,7 +498,8 @@ let isBinaryLike = (value: unknown) => {
|
|
|
487
498
|
|
|
488
499
|
let serializeTextBody = (
|
|
489
500
|
value: unknown,
|
|
490
|
-
contentType?: string
|
|
501
|
+
contentType?: string,
|
|
502
|
+
redactor?: AuthConfigSecretRedactor
|
|
491
503
|
): SlateHttpTraceTextBody | undefined => {
|
|
492
504
|
let normalizedContentType = normalizeContentType(contentType);
|
|
493
505
|
|
|
@@ -496,19 +508,23 @@ let serializeTextBody = (
|
|
|
496
508
|
}
|
|
497
509
|
|
|
498
510
|
if (typeof value === 'string') {
|
|
499
|
-
return sanitizeTextBody(value, normalizedContentType);
|
|
511
|
+
return sanitizeTextBody(value, normalizedContentType, redactor);
|
|
500
512
|
}
|
|
501
513
|
|
|
502
514
|
if (value instanceof URLSearchParams) {
|
|
503
|
-
return sanitizeTextBody(value.toString(), normalizedContentType);
|
|
515
|
+
return sanitizeTextBody(value.toString(), normalizedContentType, redactor);
|
|
504
516
|
}
|
|
505
517
|
|
|
506
518
|
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
507
|
-
return sanitizeTextBody(String(value), normalizedContentType);
|
|
519
|
+
return sanitizeTextBody(String(value), normalizedContentType, redactor);
|
|
508
520
|
}
|
|
509
521
|
|
|
510
522
|
if (Array.isArray(value) || isRecord(value)) {
|
|
511
|
-
return sanitizeTextBody(
|
|
523
|
+
return sanitizeTextBody(
|
|
524
|
+
JSON.stringify(redactStructuredValue(value, 0, redactor)),
|
|
525
|
+
'application/json',
|
|
526
|
+
redactor
|
|
527
|
+
);
|
|
512
528
|
}
|
|
513
529
|
|
|
514
530
|
return undefined;
|
|
@@ -533,18 +549,21 @@ let decodeRedactedMarker = (value: string) =>
|
|
|
533
549
|
? value
|
|
534
550
|
: value.split(REDACTED_ENCODED).join(REDACTED_VALUE);
|
|
535
551
|
|
|
536
|
-
let redactHashFragment = (hash: string) => {
|
|
552
|
+
let redactHashFragment = (hash: string, redactor?: AuthConfigSecretRedactor) => {
|
|
537
553
|
if (!hash?.includes('=')) return hash;
|
|
538
554
|
let withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
539
555
|
let redacted = withoutHash.replace(
|
|
540
556
|
/(?<![A-Za-z0-9])([A-Za-z0-9][A-Za-z0-9_\-.]*)=([^&]*)/g,
|
|
541
|
-
(match, key: string,
|
|
542
|
-
isUrlFormSecretKeyName(key)
|
|
557
|
+
(match, key: string, rawValue: string) => {
|
|
558
|
+
if (isUrlFormSecretKeyName(key)) return `${key}=${REDACTED_VALUE}`;
|
|
559
|
+
let literalRedacted = redactor ? redactor.redact(rawValue) : rawValue;
|
|
560
|
+
return literalRedacted !== rawValue ? `${key}=${literalRedacted}` : match;
|
|
561
|
+
}
|
|
543
562
|
);
|
|
544
563
|
return redacted ? `#${redacted}` : '';
|
|
545
564
|
};
|
|
546
565
|
|
|
547
|
-
let sanitizeUrl = (value: string) => {
|
|
566
|
+
let sanitizeUrl = (value: string, redactor?: AuthConfigSecretRedactor) => {
|
|
548
567
|
if (!value) return value;
|
|
549
568
|
|
|
550
569
|
try {
|
|
@@ -558,14 +577,21 @@ let sanitizeUrl = (value: string) => {
|
|
|
558
577
|
for (let key of Array.from(url.searchParams.keys())) {
|
|
559
578
|
if (isUrlFormSecretKeyName(key)) {
|
|
560
579
|
url.searchParams.set(key, REDACTED_VALUE);
|
|
580
|
+
continue;
|
|
561
581
|
}
|
|
582
|
+
|
|
583
|
+
// Not a secret-looking key -- still catch a literal auth-config secret value.
|
|
584
|
+
let rawValue = url.searchParams.get(key) ?? '';
|
|
585
|
+
let literalRedacted = redactor ? redactor.redact(rawValue) : rawValue;
|
|
586
|
+
if (literalRedacted !== rawValue) url.searchParams.set(key, literalRedacted);
|
|
562
587
|
}
|
|
563
588
|
|
|
564
|
-
url.hash = redactHashFragment(url.hash);
|
|
589
|
+
url.hash = redactHashFragment(url.hash, redactor);
|
|
565
590
|
|
|
566
591
|
return decodeRedactedMarker(url.toString());
|
|
567
592
|
} catch {
|
|
568
|
-
|
|
593
|
+
let text = redactor ? redactor.redact(value) : value;
|
|
594
|
+
return text.replace(
|
|
569
595
|
/([?&]([^=&#]+)=)([^&#]+)/g,
|
|
570
596
|
(_match, prefix: string, key: string, rawValue: string) =>
|
|
571
597
|
isUrlFormSecretKeyName(key) ? `${prefix}${REDACTED_VALUE}` : `${prefix}${rawValue}`
|
|
@@ -618,9 +644,12 @@ let headerEntries = (headers: unknown): [string, string][] => {
|
|
|
618
644
|
return [];
|
|
619
645
|
};
|
|
620
646
|
|
|
621
|
-
let sanitizeHeaders = (headers: unknown) => {
|
|
647
|
+
let sanitizeHeaders = (headers: unknown, redactor?: AuthConfigSecretRedactor) => {
|
|
622
648
|
let entries = headerEntries(headers)
|
|
623
|
-
.map(
|
|
649
|
+
.map(
|
|
650
|
+
([key, value]) =>
|
|
651
|
+
[key.toLowerCase(), sanitizeScalar(value, redactor).text] as [string, string]
|
|
652
|
+
)
|
|
624
653
|
.filter(([key]) => isSafeHeaderName(key));
|
|
625
654
|
|
|
626
655
|
if (entries.length === 0) return undefined;
|
|
@@ -668,6 +697,7 @@ export let attachHttpTraceDraft = (
|
|
|
668
697
|
) => {
|
|
669
698
|
let traceAwareConfig = config as TraceAwareAxiosRequestConfig;
|
|
670
699
|
let contentType = getContentType(config.headers);
|
|
700
|
+
let redactor = new AuthConfigSecretRedactor(context._getAuthConfigForRedaction());
|
|
671
701
|
|
|
672
702
|
traceAwareConfig.__slatesHttpTraceDraft = {
|
|
673
703
|
context,
|
|
@@ -675,10 +705,12 @@ export let attachHttpTraceDraft = (
|
|
|
675
705
|
startedAtMs: Date.now(),
|
|
676
706
|
request: {
|
|
677
707
|
method: (config.method ?? 'GET').toUpperCase(),
|
|
678
|
-
url: sanitizeUrl(buildUrl(config.baseURL, config.url)),
|
|
679
|
-
...(sanitizeHeaders(config.headers
|
|
680
|
-
|
|
681
|
-
|
|
708
|
+
url: sanitizeUrl(buildUrl(config.baseURL, config.url), redactor),
|
|
709
|
+
...(sanitizeHeaders(config.headers, redactor)
|
|
710
|
+
? { headers: sanitizeHeaders(config.headers, redactor) }
|
|
711
|
+
: {}),
|
|
712
|
+
...(serializeTextBody(config.data, contentType, redactor)
|
|
713
|
+
? { body: serializeTextBody(config.data, contentType, redactor) }
|
|
682
714
|
: {})
|
|
683
715
|
}
|
|
684
716
|
};
|
|
@@ -703,6 +735,7 @@ export let recordHttpTraceFromResponse = (response: AxiosResponse) => {
|
|
|
703
735
|
if (!draft) return response;
|
|
704
736
|
clearTraceDraft(config, draft);
|
|
705
737
|
|
|
738
|
+
let redactor = new AuthConfigSecretRedactor(draft.context._getAuthConfigForRedaction());
|
|
706
739
|
let contentType = getContentType(response.headers);
|
|
707
740
|
draft.context.recordHttpTrace({
|
|
708
741
|
startedAt: draft.startedAt,
|
|
@@ -711,11 +744,11 @@ export let recordHttpTraceFromResponse = (response: AxiosResponse) => {
|
|
|
711
744
|
response: {
|
|
712
745
|
status: response.status,
|
|
713
746
|
...(response.statusText ? { statusText: response.statusText } : {}),
|
|
714
|
-
...(sanitizeHeaders(response.headers)
|
|
715
|
-
? { headers: sanitizeHeaders(response.headers) }
|
|
747
|
+
...(sanitizeHeaders(response.headers, redactor)
|
|
748
|
+
? { headers: sanitizeHeaders(response.headers, redactor) }
|
|
716
749
|
: {}),
|
|
717
|
-
...(serializeTextBody(response.data, contentType)
|
|
718
|
-
? { body: serializeTextBody(response.data, contentType) }
|
|
750
|
+
...(serializeTextBody(response.data, contentType, redactor)
|
|
751
|
+
? { body: serializeTextBody(response.data, contentType, redactor) }
|
|
719
752
|
: {})
|
|
720
753
|
}
|
|
721
754
|
});
|
|
@@ -729,6 +762,7 @@ export let recordHttpTraceFromError = (error: AxiosError) => {
|
|
|
729
762
|
if (!draft) return;
|
|
730
763
|
clearTraceDraft(config, draft);
|
|
731
764
|
|
|
765
|
+
let redactor = new AuthConfigSecretRedactor(draft.context._getAuthConfigForRedaction());
|
|
732
766
|
let responseContentType = getContentType(error.response?.headers);
|
|
733
767
|
draft.context.recordHttpTrace({
|
|
734
768
|
startedAt: draft.startedAt,
|
|
@@ -739,11 +773,11 @@ export let recordHttpTraceFromError = (error: AxiosError) => {
|
|
|
739
773
|
response: {
|
|
740
774
|
status: error.response.status,
|
|
741
775
|
...(error.response.statusText ? { statusText: error.response.statusText } : {}),
|
|
742
|
-
...(sanitizeHeaders(error.response.headers)
|
|
743
|
-
? { headers: sanitizeHeaders(error.response.headers) }
|
|
776
|
+
...(sanitizeHeaders(error.response.headers, redactor)
|
|
777
|
+
? { headers: sanitizeHeaders(error.response.headers, redactor) }
|
|
744
778
|
: {}),
|
|
745
|
-
...(serializeTextBody(error.response.data, responseContentType)
|
|
746
|
-
? { body: serializeTextBody(error.response.data, responseContentType) }
|
|
779
|
+
...(serializeTextBody(error.response.data, responseContentType, redactor)
|
|
780
|
+
? { body: serializeTextBody(error.response.data, responseContentType, redactor) }
|
|
747
781
|
: {})
|
|
748
782
|
}
|
|
749
783
|
}
|