@apifuse/provider-sdk 2.2.0-beta.67 → 2.2.0-beta.69
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/AUTHORING.md +34 -2
- package/CHANGELOG.md +8 -0
- package/README.md +18 -7
- package/bin/apifuse-record.ts +81 -21
- package/bin/apifuse-submit-check.ts +170 -28
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/fixture-sanitization.js +159 -15
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +549 -11
- package/dist/runtime/diagnostic-redactor.d.ts +6 -1
- package/dist/runtime/diagnostic-redactor.js +16 -14
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +17 -103
- package/dist/runtime/instrumentation.js +4 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/runtime/redirect-hop-headers.d.ts +19 -0
- package/dist/runtime/redirect-hop-headers.js +58 -0
- package/dist/runtime/redirects.d.ts +33 -1
- package/dist/runtime/redirects.js +113 -0
- package/dist/runtime/resolver-span-attributes.d.ts +34 -0
- package/dist/runtime/resolver-span-attributes.js +93 -0
- package/dist/runtime/resolver-usage.d.ts +8 -0
- package/dist/runtime/resolver-usage.js +5 -11
- package/dist/runtime/resolver-vendors/browser.js +5 -4
- package/dist/runtime/resolver-vendors/capsolver.js +11 -10
- package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
- package/dist/runtime/resolver.js +7 -6
- package/dist/runtime/stealth-owned-headers.d.ts +21 -8
- package/dist/runtime/stealth-owned-headers.js +28 -8
- package/dist/runtime/stealth-telemetry.d.ts +1 -1
- package/dist/runtime/stealth.js +158 -46
- package/dist/runtime/trace.js +2 -2
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +45 -30
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/dist/testing/run.js +14 -7
- package/dist/trace-sanitization.js +1 -1
- package/dist/types.d.ts +30 -6
- package/package.json +2 -2
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/fixture-sanitization.ts +175 -17
- package/src/lint.ts +595 -10
- package/src/runtime/diagnostic-redactor.ts +19 -13
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +24 -112
- package/src/runtime/instrumentation.ts +4 -1
- package/src/runtime/provider.ts +15 -0
- package/src/runtime/redirect-hop-headers.ts +71 -0
- package/src/runtime/redirects.ts +150 -1
- package/src/runtime/resolver-span-attributes.ts +139 -0
- package/src/runtime/resolver-usage.ts +8 -5
- package/src/runtime/resolver-vendors/browser.ts +5 -4
- package/src/runtime/resolver-vendors/capsolver.ts +11 -10
- package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
- package/src/runtime/resolver.ts +7 -6
- package/src/runtime/stealth-owned-headers.ts +34 -8
- package/src/runtime/stealth-telemetry.ts +6 -1
- package/src/runtime/stealth.ts +180 -58
- package/src/runtime/trace.ts +6 -2
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +58 -31
- package/src/server/state-backend-report.ts +185 -0
- package/src/testing/run.ts +15 -2
- package/src/trace-sanitization.ts +1 -1
- package/src/types.ts +30 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
1
2
|
export const REDACTED_FIXTURE_VALUE = "[REDACTED]";
|
|
2
3
|
const OPAQUE_TOKEN = /^[A-Za-z0-9_+/=.:~-]+$/;
|
|
3
4
|
const OPAQUE_TOKEN_RUN = /[A-Za-z0-9_+/=.:~-]{24,}/g;
|
|
@@ -6,12 +7,39 @@ const EMAIL_ADDRESS_RUN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
|
6
7
|
const DIAGNOSTIC_URL_SENTINEL_DELIMITER = String.fromCodePoint(0);
|
|
7
8
|
const DIAGNOSTIC_URL_SENTINEL_RUN = new RegExp(`${DIAGNOSTIC_URL_SENTINEL_DELIMITER}APIFUSE_URL(\\d+)${DIAGNOSTIC_URL_SENTINEL_DELIMITER}`, "g");
|
|
8
9
|
const PEM_PRIVATE_KEY = /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g;
|
|
10
|
+
/** RFC 9110 §11.4 `token68`, the single credential token of Bearer, Basic, Negotiate and NTLM. */
|
|
11
|
+
const TOKEN68 = "[A-Za-z0-9._~+/-]+=*";
|
|
12
|
+
/**
|
|
13
|
+
* A credential header's value is the whole RFC 9110 `credentials` production
|
|
14
|
+
* (`auth-scheme SP token68`), so the scheme and its token are one value, not two words.
|
|
15
|
+
* The key starts at a word boundary so a long word run is scanned once, not from every offset.
|
|
16
|
+
*/
|
|
17
|
+
const CREDENTIALS_HEADER_ASSIGNMENT = new RegExp(String.raw `(?<![\w-])((["']?)[\w-]*authorization\2\s*[:=]\s*)[A-Za-z][\w.+-]*[ \t]+${TOKEN68}(?![^\s,;&])`, "gi");
|
|
18
|
+
/**
|
|
19
|
+
* Keyless echoes of the token68 schemes whose token is always a credential. Only the scheme and
|
|
20
|
+
* its whitespace are matched, so matches never overlap a later scheme; the token is the whole
|
|
21
|
+
* opaque run that follows, so no partial redaction leaves a remainder too short for the
|
|
22
|
+
* opaque-token scan. Negotiate and NTLM match only their registered spelling so prose such as
|
|
23
|
+
* "failed to negotiate TLS" is retained.
|
|
24
|
+
*/
|
|
25
|
+
const TOKEN68_SCHEMES = [/\bBearer\s+/gi, /\b(?:Negotiate|NTLM)\s+/g];
|
|
26
|
+
/** A keyless `Basic` token is a credential only when it decodes to `user-id:password`. */
|
|
27
|
+
const BASIC_SCHEME = /\bBasic\s+/gi;
|
|
28
|
+
const OPAQUE_TOKEN_CHARACTER = /[A-Za-z0-9_+/=.:~-]/;
|
|
29
|
+
const BASE64_CHARACTER = /[A-Za-z0-9+/]/;
|
|
30
|
+
const ASSIGNMENT_VALUE_DELIMITER = /[\s,;&]/;
|
|
31
|
+
/**
|
|
32
|
+
* A key and its `:`/`=`, starting at a word boundary so a word run is scanned once. Values are
|
|
33
|
+
* never consumed, so an assignment inside another value is still found.
|
|
34
|
+
*/
|
|
35
|
+
const ASSIGNMENT_KEY = /(["']?)(?<![\w-])([\w-]+)\1\s*[:=]\s*/gi;
|
|
9
36
|
/** Matches credential field names without treating benign prefixes such as `author` as `auth`. */
|
|
10
37
|
export function isSensitiveFixtureKey(key) {
|
|
11
38
|
const normalized = key.replace(/[-_\s]/g, "").toLowerCase();
|
|
12
39
|
const candidates = [normalized, normalized.replace(/(?:value|payload|header)$/, "")];
|
|
13
40
|
return candidates.some((candidate) => /^(?:authorization|authentication|auth|bearer|cookie|credential|password|passwd|privatekey|secret|session|sessionid|token)$/.test(candidate) ||
|
|
14
|
-
|
|
41
|
+
// Unanchored at the start so vendor-prefixed headers (`x-api-key`) match too.
|
|
42
|
+
/(?:api|client|service|access|consumer)(?:key|secret|token)$/.test(candidate) ||
|
|
15
43
|
/(?:authorization|credential|password|passwd|privatekey|secret|sessionid|token)$/.test(candidate));
|
|
16
44
|
}
|
|
17
45
|
/**
|
|
@@ -50,7 +78,7 @@ export function sanitizeFixtureString(value) {
|
|
|
50
78
|
const index = retainedUrls.push(isCredentialBearingUrl(url) ? sanitizeUrlForLogs(url) : url) - 1;
|
|
51
79
|
return `APIFUSEURL${index}X`;
|
|
52
80
|
});
|
|
53
|
-
sanitized =
|
|
81
|
+
sanitized = redactCredentialSpans(sanitized, [credentialHeaderSpans, sensitiveAssignmentSpans]);
|
|
54
82
|
sanitized = sanitized.replace(OPAQUE_TOKEN_RUN, (candidate) => isSensitiveFixtureValue(candidate) ? REDACTED_FIXTURE_VALUE : candidate);
|
|
55
83
|
sanitized = sanitized.replace(/APIFUSEURL(\d+)X/g, (_match, index) => retainedUrls[Number(index)] ?? REDACTED_FIXTURE_VALUE);
|
|
56
84
|
return sanitized;
|
|
@@ -135,9 +163,12 @@ export function sanitizeDiagnosticText(value) {
|
|
|
135
163
|
const index = retainedUrls.push(sanitizeUrlForLogs(url)) - 1;
|
|
136
164
|
return `${DIAGNOSTIC_URL_SENTINEL_DELIMITER}APIFUSE_URL${index}${DIAGNOSTIC_URL_SENTINEL_DELIMITER}`;
|
|
137
165
|
})
|
|
138
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED_FIXTURE_VALUE}`)
|
|
139
166
|
.replace(EMAIL_ADDRESS_RUN, REDACTED_FIXTURE_VALUE);
|
|
140
|
-
sanitized =
|
|
167
|
+
sanitized = redactCredentialSpans(sanitized, [
|
|
168
|
+
credentialHeaderSpans,
|
|
169
|
+
keylessCredentialSpans,
|
|
170
|
+
sensitiveAssignmentSpans,
|
|
171
|
+
]);
|
|
141
172
|
sanitized = sanitized.replace(OPAQUE_TOKEN_RUN, (candidate, offset, source) => {
|
|
142
173
|
if (/^(?:request|trace|correlation)[-_]?id[:=]/i.test(candidate))
|
|
143
174
|
return candidate;
|
|
@@ -149,17 +180,130 @@ export function sanitizeDiagnosticText(value) {
|
|
|
149
180
|
sanitized = sanitized.replace(DIAGNOSTIC_URL_SENTINEL_RUN, (_match, index) => retainedUrls[Number(index)] ?? REDACTED_FIXTURE_VALUE);
|
|
150
181
|
return encodeDiagnosticControls(sanitized);
|
|
151
182
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Every credential rule scans the same unmodified text and reports spans; the output redacts
|
|
185
|
+
* their union. No rule's rewrite can hide a key or a scheme word from another rule, so adding a
|
|
186
|
+
* rule can only redact more.
|
|
187
|
+
*/
|
|
188
|
+
function redactCredentialSpans(value, rules) {
|
|
189
|
+
const spans = rules
|
|
190
|
+
.flatMap((rule) => [...rule(value)])
|
|
191
|
+
.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
192
|
+
let output = "";
|
|
193
|
+
let cursor = 0;
|
|
194
|
+
let open;
|
|
195
|
+
const close = (span) => {
|
|
196
|
+
output += value.slice(cursor, span[0]) + REDACTED_FIXTURE_VALUE;
|
|
197
|
+
cursor = span[1];
|
|
198
|
+
};
|
|
199
|
+
for (const [start, end] of spans) {
|
|
200
|
+
if (open && start <= open[1]) {
|
|
201
|
+
open[1] = Math.max(open[1], end);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (open)
|
|
205
|
+
close(open);
|
|
206
|
+
open = [start, end];
|
|
207
|
+
}
|
|
208
|
+
if (open)
|
|
209
|
+
close(open);
|
|
210
|
+
return output + value.slice(cursor);
|
|
211
|
+
}
|
|
212
|
+
/** The scheme and token of a credential header value, as one span. */
|
|
213
|
+
function* credentialHeaderSpans(value) {
|
|
214
|
+
for (const match of value.matchAll(CREDENTIALS_HEADER_ASSIGNMENT)) {
|
|
215
|
+
yield [match.index + match[1].length, match.index + match[0].length];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Tokens after keyless `Bearer`/`Negotiate`/`NTLM`, and `Basic` tokens that decode to `user-id:password`. */
|
|
219
|
+
function* keylessCredentialSpans(value) {
|
|
220
|
+
const opaqueEnds = runEnds(value, OPAQUE_TOKEN_CHARACTER);
|
|
221
|
+
for (const scheme of TOKEN68_SCHEMES) {
|
|
222
|
+
for (const match of value.matchAll(scheme)) {
|
|
223
|
+
const start = match.index + match[0].length;
|
|
224
|
+
if (opaqueEnds[start] > start)
|
|
225
|
+
yield [start, opaqueEnds[start]];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const base64Ends = runEnds(value, BASE64_CHARACTER);
|
|
229
|
+
for (const match of value.matchAll(BASIC_SCHEME)) {
|
|
230
|
+
const start = match.index + match[0].length;
|
|
231
|
+
const payloadEnd = base64Ends[start];
|
|
232
|
+
let end = payloadEnd;
|
|
233
|
+
while (end < payloadEnd + 2 && value[end] === "=")
|
|
234
|
+
end++;
|
|
235
|
+
// The base64 token must be the whole opaque run, not the prefix of a longer token.
|
|
236
|
+
if (payloadEnd === start || end !== opaqueEnds[start])
|
|
237
|
+
continue;
|
|
238
|
+
if (Buffer.from(value.slice(start, end), "base64").includes(0x3a))
|
|
239
|
+
yield [start, end];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Values of credential-named keys; quotes around a quoted value are kept. Every key is visited,
|
|
244
|
+
* including keys inside another key's value (`auth: {"password": "x"}`), and each value's extent
|
|
245
|
+
* is read from precomputed tables, so the scan stays linear in the text length.
|
|
246
|
+
*/
|
|
247
|
+
function* sensitiveAssignmentSpans(value) {
|
|
248
|
+
let extents;
|
|
249
|
+
for (const key of value.matchAll(ASSIGNMENT_KEY)) {
|
|
250
|
+
const name = key[2];
|
|
251
|
+
if (!isSensitiveFixtureKey(name) && name.toLowerCase() !== "key")
|
|
252
|
+
continue;
|
|
253
|
+
extents ??= assignmentValueExtents(value);
|
|
254
|
+
const start = key.index + key[0].length;
|
|
255
|
+
const quote = value[start];
|
|
256
|
+
const closing = quote === '"'
|
|
257
|
+
? extents.doubleQuoteClose[start + 1]
|
|
258
|
+
: quote === "'"
|
|
259
|
+
? extents.singleQuoteClose[start + 1]
|
|
260
|
+
: -1;
|
|
261
|
+
if (closing !== -1) {
|
|
262
|
+
yield [start + 1, closing];
|
|
263
|
+
}
|
|
264
|
+
else if (extents.unquotedEnds[start] > start) {
|
|
265
|
+
yield [start, extents.unquotedEnds[start]];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function assignmentValueExtents(value) {
|
|
270
|
+
const unquotedEnds = new Int32Array(value.length + 1);
|
|
271
|
+
unquotedEnds[value.length] = value.length;
|
|
272
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
273
|
+
unquotedEnds[index] = ASSIGNMENT_VALUE_DELIMITER.test(value[index])
|
|
274
|
+
? index
|
|
275
|
+
: unquotedEnds[index + 1];
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
unquotedEnds,
|
|
279
|
+
doubleQuoteClose: closingQuotes(value, '"'),
|
|
280
|
+
singleQuoteClose: closingQuotes(value, "'"),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/** Right-to-left: reading a quoted body from index i either closes at a quote or skips an escape pair. */
|
|
284
|
+
function closingQuotes(value, quote) {
|
|
285
|
+
const closing = new Int32Array(value.length + 2).fill(-1);
|
|
286
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
287
|
+
const character = value[index];
|
|
288
|
+
closing[index] =
|
|
289
|
+
character === quote
|
|
290
|
+
? index
|
|
291
|
+
: character === "\\"
|
|
292
|
+
? /[^\n\r\u2028\u2029]/.test(value[index + 1] ?? "\n")
|
|
293
|
+
? closing[index + 2]
|
|
294
|
+
: -1
|
|
295
|
+
: closing[index + 1];
|
|
296
|
+
}
|
|
297
|
+
return closing;
|
|
298
|
+
}
|
|
299
|
+
/** For each index, the end of the run of `character` starting there, in one right-to-left pass. */
|
|
300
|
+
function runEnds(value, character) {
|
|
301
|
+
const ends = new Int32Array(value.length + 1);
|
|
302
|
+
ends[value.length] = value.length;
|
|
303
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
304
|
+
ends[index] = character.test(value[index]) ? ends[index + 1] : index;
|
|
305
|
+
}
|
|
306
|
+
return ends;
|
|
163
307
|
}
|
|
164
308
|
function isCredentialBearingUrl(value) {
|
|
165
309
|
try {
|
package/dist/lint.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export declare function lintProvider(provider: {
|
|
|
83
83
|
fixtures?: unknown;
|
|
84
84
|
handler?: unknown;
|
|
85
85
|
source?: string;
|
|
86
|
+
/** Read structurally by the health-check monitorability rules. */
|
|
87
|
+
healthCheck?: unknown;
|
|
86
88
|
errorCodes?: ReadonlyArray<{
|
|
87
89
|
code: string;
|
|
88
90
|
status?: number;
|