@sentry/roach 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/README.md +561 -2
- package/package.json +74 -3
- package/src/action.ts +81 -0
- package/src/addresses.ts +103 -0
- package/src/certificates.ts +169 -0
- package/src/cli.ts +35 -0
- package/src/client.ts +211 -0
- package/src/config.ts +141 -0
- package/src/gcs.ts +145 -0
- package/src/recorder.ts +464 -0
- package/src/recordings.ts +42 -0
- package/src/report.ts +55 -0
- package/src/request-key.ts +165 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +544 -0
- package/src/service.ts +572 -0
- package/src/store.ts +47 -0
- package/src/streams.ts +115 -0
- package/src/types.ts +130 -0
- package/src/usage.ts +120 -0
- package/src/values.ts +229 -0
- package/src/vitest-setup.ts +66 -0
- package/src/vitest.ts +108 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key of a recorded request.
|
|
3
|
+
*
|
|
4
|
+
* The key is the hash of the rule, the method, the URL, the key headers,
|
|
5
|
+
* and the body. A JSON body has sorted object keys, so key order does not
|
|
6
|
+
* matter. Each changing value of the rule is `<<name>>` in the key
|
|
7
|
+
* (`values.ts`).
|
|
8
|
+
*
|
|
9
|
+
* The patterns of `values` come from the client, and all runs share one
|
|
10
|
+
* process. A slow pattern would block every run, so a key has a time limit
|
|
11
|
+
* (`KEY_TIME_LIMIT`).
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
15
|
+
import { THINKING_BLOCK_TYPES } from "./streams.ts";
|
|
16
|
+
import type { RecordingRule } from "./types.ts";
|
|
17
|
+
import {
|
|
18
|
+
extractValues,
|
|
19
|
+
type KnownValues,
|
|
20
|
+
type RequestValues,
|
|
21
|
+
type ValuePatterns,
|
|
22
|
+
withTimeLimit,
|
|
23
|
+
} from "./values.ts";
|
|
24
|
+
|
|
25
|
+
/** Change this to make every recording a miss. */
|
|
26
|
+
const KEY_VERSION = "http-v1";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The time that the key of one request can take: 1 s, and 0.5 s for each
|
|
30
|
+
* MiB of the body. The built-in patterns take less than 0.1 s for each
|
|
31
|
+
* MiB. A key that takes longer fails its request, and other runs go on.
|
|
32
|
+
*/
|
|
33
|
+
const KEY_TIME_LIMIT = { baseMs: 1000, msPerMiB: 500 };
|
|
34
|
+
|
|
35
|
+
/** The parts of a request that make its key. */
|
|
36
|
+
interface RequestToKey {
|
|
37
|
+
method: string;
|
|
38
|
+
url: string;
|
|
39
|
+
headers: IncomingHttpHeaders;
|
|
40
|
+
body: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A request as the key sees it. */
|
|
44
|
+
export interface KeyedRequest {
|
|
45
|
+
/** The name of the recording file, without `.json`. */
|
|
46
|
+
key: string;
|
|
47
|
+
/**
|
|
48
|
+
* The changing values of the request outside thinking blocks, in their
|
|
49
|
+
* order. They fill the placeholders of a replayed response.
|
|
50
|
+
*/
|
|
51
|
+
values: RequestValues;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** JSON with sorted object keys, so equal bodies give equal keys. */
|
|
55
|
+
function stableStringify(value: unknown): string {
|
|
56
|
+
if (value === null || typeof value !== "object") {
|
|
57
|
+
return JSON.stringify(value);
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
61
|
+
}
|
|
62
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
63
|
+
.filter(([, item]) => item !== undefined)
|
|
64
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
65
|
+
return `{${entries
|
|
66
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
67
|
+
.join(",")}}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseJson(body: string): unknown {
|
|
71
|
+
try {
|
|
72
|
+
return body ? JSON.parse(body) : undefined;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Marks the place of a thinking block in the text of a body. */
|
|
79
|
+
const THINKING_MARK = /"\uE000(\d+)"/g;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The text of a JSON value with `<<name>>` for each changing value, and the
|
|
83
|
+
* values outside thinking blocks.
|
|
84
|
+
*
|
|
85
|
+
* A replay keeps the recorded text of a thinking block (`streams.ts`), and
|
|
86
|
+
* a later request sends it back. So its values come from the recording
|
|
87
|
+
* run. They are in the key, but they do not fill placeholders, and known
|
|
88
|
+
* values of this run are not looked for in them.
|
|
89
|
+
*/
|
|
90
|
+
function normalizeJson(
|
|
91
|
+
json: unknown,
|
|
92
|
+
patterns: ValuePatterns | undefined,
|
|
93
|
+
known: KnownValues | undefined,
|
|
94
|
+
): { text: string; values: RequestValues } {
|
|
95
|
+
const thinking: string[] = [];
|
|
96
|
+
const mark = (value: unknown): unknown => {
|
|
97
|
+
if (Array.isArray(value)) return value.map(mark);
|
|
98
|
+
if (value === null || typeof value !== "object") return value;
|
|
99
|
+
const record = value as Record<string, unknown>;
|
|
100
|
+
if (THINKING_BLOCK_TYPES.has(String(record.type))) {
|
|
101
|
+
thinking.push(extractValues(stableStringify(record), patterns).text);
|
|
102
|
+
return `\uE000${thinking.length - 1}`;
|
|
103
|
+
}
|
|
104
|
+
return Object.fromEntries(
|
|
105
|
+
Object.entries(record).map(([name, item]) => [name, mark(item)]),
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
const { text, values } = extractValues(
|
|
109
|
+
stableStringify(mark(json)),
|
|
110
|
+
patterns,
|
|
111
|
+
known,
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
text: text.replace(THINKING_MARK, (_, index) => thinking[Number(index)]!),
|
|
115
|
+
values,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sha256 = (text: string) =>
|
|
120
|
+
createHash("sha256").update(text).digest("hex");
|
|
121
|
+
/**
|
|
122
|
+
* The key and the changing values of a request. `known` holds
|
|
123
|
+
* the values of earlier requests in the same session (`values.ts`). It
|
|
124
|
+
* throws an error with HTTP status 400 when it takes too long.
|
|
125
|
+
*/
|
|
126
|
+
export function keyRequest(
|
|
127
|
+
rule: RecordingRule,
|
|
128
|
+
request: RequestToKey,
|
|
129
|
+
known?: KnownValues,
|
|
130
|
+
): KeyedRequest {
|
|
131
|
+
const mib = Buffer.byteLength(request.body) / (1024 * 1024);
|
|
132
|
+
return withTimeLimit(
|
|
133
|
+
Math.ceil(KEY_TIME_LIMIT.baseMs + KEY_TIME_LIMIT.msPerMiB * mib),
|
|
134
|
+
() => keyRequestNow(rule, request, known),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function keyRequestNow(
|
|
139
|
+
rule: RecordingRule,
|
|
140
|
+
request: RequestToKey,
|
|
141
|
+
known?: KnownValues,
|
|
142
|
+
): KeyedRequest {
|
|
143
|
+
const headers = Object.fromEntries(
|
|
144
|
+
(rule.keyHeaders ?? []).map((name) => [
|
|
145
|
+
name,
|
|
146
|
+
String(request.headers[name] ?? ""),
|
|
147
|
+
]),
|
|
148
|
+
);
|
|
149
|
+
const json = parseJson(request.body);
|
|
150
|
+
const { text, values } =
|
|
151
|
+
json === undefined
|
|
152
|
+
? extractValues(request.body, rule.values, known)
|
|
153
|
+
: normalizeJson(json, rule.values, known);
|
|
154
|
+
const key = sha256(
|
|
155
|
+
[
|
|
156
|
+
KEY_VERSION,
|
|
157
|
+
rule.name,
|
|
158
|
+
request.method,
|
|
159
|
+
request.url,
|
|
160
|
+
...Object.entries(headers).map(([name, value]) => `${name}: ${value}`),
|
|
161
|
+
text,
|
|
162
|
+
].join("\n"),
|
|
163
|
+
);
|
|
164
|
+
return { key, values };
|
|
165
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials that the proxy must never write to disk.
|
|
3
|
+
*
|
|
4
|
+
* The proxy learns the values of credential headers from each request that
|
|
5
|
+
* it sees, and the config can add more values. It replaces each known value
|
|
6
|
+
* with `<<redacted>>` before it writes a recording.
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Request headers that can carry credentials. The match is broad on
|
|
12
|
+
* purpose: redacting a value that is not a credential costs little.
|
|
13
|
+
*/
|
|
14
|
+
const CREDENTIAL_HEADER =
|
|
15
|
+
/auth|token|secret|key|cookie|session|password|credential|signature/i;
|
|
16
|
+
/**
|
|
17
|
+
* Headers that match `CREDENTIAL_HEADER` but carry no credential. A response
|
|
18
|
+
* can show their values, and the client must get them back without a change.
|
|
19
|
+
* AI Gateway clients put a conversation id in `x-session-affinity`, and a
|
|
20
|
+
* model can repeat that id in its response. Node gives header names in
|
|
21
|
+
* lower case.
|
|
22
|
+
*/
|
|
23
|
+
const NOT_CREDENTIAL_HEADERS = new Set(["x-session-affinity"]);
|
|
24
|
+
/** Shorter parts, such as `Bearer`, are not credentials. */
|
|
25
|
+
const MIN_LENGTH = 12;
|
|
26
|
+
const REDACTED = "<<redacted>>";
|
|
27
|
+
|
|
28
|
+
/** Create the secret list of one proxy run, with the values of `initial`. */
|
|
29
|
+
export function createSecrets(initial: string[] = []) {
|
|
30
|
+
const values = new Set<string>();
|
|
31
|
+
// Keep the whole value and its parts, such as the token of
|
|
32
|
+
// `Bearer <token>` or each value of a cookie header.
|
|
33
|
+
const add = (value: string) => {
|
|
34
|
+
for (const part of [value, ...value.split(/[\s;,=]+/)]) {
|
|
35
|
+
if (part.length < MIN_LENGTH) continue;
|
|
36
|
+
values.add(part);
|
|
37
|
+
// The recorder redacts JSON text, which escapes `"`, `\`, and
|
|
38
|
+
// control characters.
|
|
39
|
+
values.add(JSON.stringify(part).slice(1, -1));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
initial.forEach(add);
|
|
43
|
+
// Redact longer values first, so no part of one stays visible.
|
|
44
|
+
const longestFirst = () =>
|
|
45
|
+
[...values].toSorted((a, b) => b.length - a.length);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
/** Learn the credentials in the headers of a request. */
|
|
49
|
+
learn(headers: IncomingHttpHeaders): void {
|
|
50
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
51
|
+
if (!CREDENTIAL_HEADER.test(name) || value === undefined) continue;
|
|
52
|
+
if (NOT_CREDENTIAL_HEADERS.has(name)) continue;
|
|
53
|
+
for (const item of Array.isArray(value) ? value : [value]) add(item);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/** `text` with each known credential replaced. */
|
|
58
|
+
redact(text: string): string {
|
|
59
|
+
let result = text;
|
|
60
|
+
for (const value of longestFirst()) {
|
|
61
|
+
result = result.replaceAll(value, REDACTED);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|