@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
package/src/error-resolution.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProviderErrorCategory } from "./observability.js";
|
|
1
2
|
import type { ProviderErrorStatus } from "./types.js";
|
|
2
3
|
|
|
3
4
|
// This set suppresses the unregistered-provider-error-code signal for codes
|
|
@@ -120,11 +121,89 @@ export const SDK_RUNTIME_OWNED_ERROR_CODES = new Set([
|
|
|
120
121
|
"not_found",
|
|
121
122
|
]);
|
|
122
123
|
|
|
124
|
+
/** One registered upstream code: the status the SDK serves and who failed. */
|
|
125
|
+
export type SdkUpstreamErrorCodeRegistration = {
|
|
126
|
+
readonly status: ProviderErrorStatus;
|
|
127
|
+
readonly category: ProviderErrorCategory;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Registered codes that name an upstream outcome. A row states the status the
|
|
131
|
+
// SDK serves and the observability category together, so an upstream code
|
|
132
|
+
// cannot be registered without saying who failed. Serve reads the category
|
|
133
|
+
// for a `ProviderError` that carries one of these codes and no explicit
|
|
134
|
+
// `category`, and the public `source` follows from it (`sourceForCategory`).
|
|
135
|
+
// Without a row the category defaults to `provider_error` → `source:
|
|
136
|
+
// "apifuse"`, and the health monitor cannot tie a real upstream failure to the
|
|
137
|
+
// upstream: it publishes `monitoring_unavailable` instead of `down`.
|
|
138
|
+
//
|
|
139
|
+
// The category is `categoryForStatus(status)` unless the code names a
|
|
140
|
+
// narrower class than its status can: a schema change, a bot wall, an
|
|
141
|
+
// upstream refusal of the platform-managed key (served 400, see below).
|
|
142
|
+
// Every row is a code the fleet throws for something the upstream did or
|
|
143
|
+
// said; caller-side and SDK-internal codes stay in the table below.
|
|
144
|
+
export const SDK_UPSTREAM_ERROR_CODE_REGISTRY: ReadonlyMap<
|
|
145
|
+
string,
|
|
146
|
+
SdkUpstreamErrorCodeRegistration
|
|
147
|
+
> = new Map<string, SdkUpstreamErrorCodeRegistration>([
|
|
148
|
+
// The SDK's own unknown-operation and unknown-route throws reuse the
|
|
149
|
+
// not-found spellings; they set an explicit category and stay APIFuse's.
|
|
150
|
+
["NOT_FOUND", { status: 404, category: "upstream_http" }],
|
|
151
|
+
["not_found", { status: 404, category: "upstream_http" }],
|
|
152
|
+
["NO_DATA", { status: 404, category: "upstream_http" }],
|
|
153
|
+
["RATE_LIMITED", { status: 429, category: "upstream_rate_limited" }],
|
|
154
|
+
["UPSTREAM_RATE_LIMIT", { status: 429, category: "upstream_rate_limited" }],
|
|
155
|
+
[
|
|
156
|
+
"LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR",
|
|
157
|
+
{ status: 429, category: "upstream_rate_limited" },
|
|
158
|
+
],
|
|
159
|
+
// Deterministic upstream business refusal (honest-provider-error-
|
|
160
|
+
// contract): the upstream evaluated the request and said no under its
|
|
161
|
+
// own rules — a conflict with upstream state, never a 5xx.
|
|
162
|
+
["UPSTREAM_REJECTED", { status: 409, category: "upstream_rejected" }],
|
|
163
|
+
["UPSTREAM_ERROR", { status: 502, category: "upstream_http" }],
|
|
164
|
+
// The upstream's bot wall or request protection refused the request. The
|
|
165
|
+
// status says only "upstream"; the code says which kind.
|
|
166
|
+
["BLOCKED", { status: 502, category: "anti_bot_blocked" }],
|
|
167
|
+
// Fleet-consensus provider codes. These are thrown by provider code, not
|
|
168
|
+
// by the SDK, so they are registered here (status mapping) and not in
|
|
169
|
+
// SDK_RUNTIME_OWNED_ERROR_CODES: an operation-declared status still wins,
|
|
170
|
+
// exactly as it does for UPSTREAM_ERROR and BLOCKED above. Registering
|
|
171
|
+
// them stops the fleet from re-declaring the same rows on every
|
|
172
|
+
// operation, and stops an undeclared throw from being served as 500.
|
|
173
|
+
//
|
|
174
|
+
// A platform-managed upstream service key the upstream refuses. With
|
|
175
|
+
// `auth.mode: "platform-managed"` the caller holds no credential, so 401
|
|
176
|
+
// ("re-authenticate") tells the caller to do something it cannot do, and
|
|
177
|
+
// 502 ("upstream is sick") promises a recovery that will never come. It is
|
|
178
|
+
// a deployment/config defect — the same class as MISSING_SECRET below,
|
|
179
|
+
// which is already an explicit 400 for that reason. The upstream is still
|
|
180
|
+
// the one refusing, so the category is `upstream_auth`; the gateway
|
|
181
|
+
// records it as `provider_error` for every non-`required` connection mode
|
|
182
|
+
// so the refusal stays provider-alertable.
|
|
183
|
+
["UPSTREAM_AUTH_ERROR", { status: 400, category: "upstream_auth" }],
|
|
184
|
+
// The upstream changed its response shape and the provider cannot
|
|
185
|
+
// normalize it. 502 because the fault is upstream of us, non-retryable
|
|
186
|
+
// because a retry returns the same broken payload.
|
|
187
|
+
["UPSTREAM_SCHEMA_ERROR", { status: 502, category: "upstream_schema_drift" }],
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The category a registered upstream code carries when its `ProviderError`
|
|
192
|
+
* sets none. `undefined` for every other code, which keeps its existing
|
|
193
|
+
* default.
|
|
194
|
+
*/
|
|
195
|
+
export function registeredUpstreamErrorCategory(
|
|
196
|
+
code: string | undefined,
|
|
197
|
+
): ProviderErrorCategory | undefined {
|
|
198
|
+
return code === undefined ? undefined : SDK_UPSTREAM_ERROR_CODE_REGISTRY.get(code)?.category;
|
|
199
|
+
}
|
|
200
|
+
|
|
123
201
|
// Canonical SDK status mapping for recognized provider-thrown error codes.
|
|
124
202
|
// serve.ts toStatusCode consults this map (after operation-declared overrides
|
|
125
203
|
// for non-SDK-owned codes), and the authoring lint treats these codes as
|
|
126
204
|
// SDK-registered. Add new codes here instead of duplicating literals in
|
|
127
|
-
// either consumer
|
|
205
|
+
// either consumer — an upstream-attributed code goes in
|
|
206
|
+
// SDK_UPSTREAM_ERROR_CODE_REGISTRY above, which feeds this map.
|
|
128
207
|
export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, ProviderErrorStatus> =
|
|
129
208
|
new Map<string, ProviderErrorStatus>([
|
|
130
209
|
["AUTH_REQUIRED", 401],
|
|
@@ -132,16 +211,6 @@ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, Provide
|
|
|
132
211
|
// Unprovisioned declared secret: a deployment/config defect, never an
|
|
133
212
|
// upstream failure — explicit 400.
|
|
134
213
|
["MISSING_SECRET", 400],
|
|
135
|
-
["NOT_FOUND", 404],
|
|
136
|
-
["not_found", 404],
|
|
137
|
-
["NO_DATA", 404],
|
|
138
|
-
["RATE_LIMITED", 429],
|
|
139
|
-
["UPSTREAM_RATE_LIMIT", 429],
|
|
140
|
-
["LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR", 429],
|
|
141
|
-
// Deterministic upstream business refusal (honest-provider-error-
|
|
142
|
-
// contract): the upstream evaluated the request and said no under its
|
|
143
|
-
// own rules — a conflict with upstream state, never a 5xx.
|
|
144
|
-
["UPSTREAM_REJECTED", 409],
|
|
145
214
|
// Only an unverifiable or foreign handle is the caller's: key and binding
|
|
146
215
|
// faults stay unmapped (500) because the engine host, not the caller, owns them.
|
|
147
216
|
["EGRESS_LEASE_INVALID", 409],
|
|
@@ -157,30 +226,11 @@ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, Provide
|
|
|
157
226
|
["HANDLE_EXPIRED", 410],
|
|
158
227
|
["HANDLE_COMMITTED", 409],
|
|
159
228
|
["HANDLE_BUSY", 409],
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// exactly as it does for UPSTREAM_ERROR and BLOCKED above. Registering
|
|
166
|
-
// them stops the fleet from re-declaring the same three rows on every
|
|
167
|
-
// operation, and stops an undeclared throw from being served as 500.
|
|
168
|
-
//
|
|
169
|
-
// A platform-managed upstream service key the upstream refuses. With
|
|
170
|
-
// `auth.mode: "platform-managed"` the caller holds no credential, so 401
|
|
171
|
-
// ("re-authenticate") tells the caller to do something it cannot do, and
|
|
172
|
-
// 502 ("upstream is sick") promises a recovery that will never come. It is
|
|
173
|
-
// a deployment/config defect — the same class as MISSING_SECRET above,
|
|
174
|
-
// which is already an explicit 400 for that reason.
|
|
175
|
-
["UPSTREAM_AUTH_ERROR", 400],
|
|
176
|
-
// The upstream changed its response shape and the provider cannot
|
|
177
|
-
// normalize it. 502 because the fault is upstream of us, non-retryable
|
|
178
|
-
// because a retry returns the same broken payload.
|
|
179
|
-
["UPSTREAM_SCHEMA_ERROR", 502],
|
|
180
|
-
// Caller-side bad input rejected by the provider. The minority spellings
|
|
181
|
-
// (INVALID_INPUT / VALIDATION_ERROR / BAD_REQUEST) are deliberately not
|
|
182
|
-
// registered: they migrate to this spelling on the contract track, and
|
|
183
|
-
// registering them here would freeze the divergence.
|
|
229
|
+
// Caller-side bad input rejected by the provider (fleet consensus, like
|
|
230
|
+
// UPSTREAM_AUTH_ERROR and UPSTREAM_SCHEMA_ERROR above). The minority
|
|
231
|
+
// spellings (INVALID_INPUT / VALIDATION_ERROR / BAD_REQUEST) are
|
|
232
|
+
// deliberately not registered: they migrate to this spelling on the
|
|
233
|
+
// contract track, and registering them here would freeze the divergence.
|
|
184
234
|
["INVALID_REQUEST", 400],
|
|
185
235
|
["OCR_UNAVAILABLE", 503],
|
|
186
236
|
["UNSUPPORTED_OCR_BACKEND", 503],
|
|
@@ -194,6 +244,10 @@ export const SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES: ReadonlyMap<string, Provide
|
|
|
194
244
|
// retry can clear, and a retryable 5xx there would turn one bad rollout
|
|
195
245
|
// into gateway-driven load.
|
|
196
246
|
["PROVIDER_ENGINE_UNAVAILABLE", 503],
|
|
247
|
+
...Array.from(
|
|
248
|
+
SDK_UPSTREAM_ERROR_CODE_REGISTRY,
|
|
249
|
+
([code, registration]) => [code, registration.status] as const,
|
|
250
|
+
),
|
|
197
251
|
]);
|
|
198
252
|
|
|
199
253
|
// Canonical retryability for the fleet-consensus codes registered above.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
1
3
|
import type { JsonValue } from "./contract-json.js";
|
|
2
4
|
|
|
3
5
|
export const REDACTED_FIXTURE_VALUE = "[REDACTED]";
|
|
@@ -13,6 +15,35 @@ const DIAGNOSTIC_URL_SENTINEL_RUN = new RegExp(
|
|
|
13
15
|
);
|
|
14
16
|
const PEM_PRIVATE_KEY =
|
|
15
17
|
/-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g;
|
|
18
|
+
/** RFC 9110 §11.4 `token68`, the single credential token of Bearer, Basic, Negotiate and NTLM. */
|
|
19
|
+
const TOKEN68 = "[A-Za-z0-9._~+/-]+=*";
|
|
20
|
+
/**
|
|
21
|
+
* A credential header's value is the whole RFC 9110 `credentials` production
|
|
22
|
+
* (`auth-scheme SP token68`), so the scheme and its token are one value, not two words.
|
|
23
|
+
* The key starts at a word boundary so a long word run is scanned once, not from every offset.
|
|
24
|
+
*/
|
|
25
|
+
const CREDENTIALS_HEADER_ASSIGNMENT = new RegExp(
|
|
26
|
+
String.raw`(?<![\w-])((["']?)[\w-]*authorization\2\s*[:=]\s*)[A-Za-z][\w.+-]*[ \t]+${TOKEN68}(?![^\s,;&])`,
|
|
27
|
+
"gi",
|
|
28
|
+
);
|
|
29
|
+
/**
|
|
30
|
+
* Keyless echoes of the token68 schemes whose token is always a credential. Only the scheme and
|
|
31
|
+
* its whitespace are matched, so matches never overlap a later scheme; the token is the whole
|
|
32
|
+
* opaque run that follows, so no partial redaction leaves a remainder too short for the
|
|
33
|
+
* opaque-token scan. Negotiate and NTLM match only their registered spelling so prose such as
|
|
34
|
+
* "failed to negotiate TLS" is retained.
|
|
35
|
+
*/
|
|
36
|
+
const TOKEN68_SCHEMES = [/\bBearer\s+/gi, /\b(?:Negotiate|NTLM)\s+/g] as const;
|
|
37
|
+
/** A keyless `Basic` token is a credential only when it decodes to `user-id:password`. */
|
|
38
|
+
const BASIC_SCHEME = /\bBasic\s+/gi;
|
|
39
|
+
const OPAQUE_TOKEN_CHARACTER = /[A-Za-z0-9_+/=.:~-]/;
|
|
40
|
+
const BASE64_CHARACTER = /[A-Za-z0-9+/]/;
|
|
41
|
+
const ASSIGNMENT_VALUE_DELIMITER = /[\s,;&]/;
|
|
42
|
+
/**
|
|
43
|
+
* A key and its `:`/`=`, starting at a word boundary so a word run is scanned once. Values are
|
|
44
|
+
* never consumed, so an assignment inside another value is still found.
|
|
45
|
+
*/
|
|
46
|
+
const ASSIGNMENT_KEY = /(["']?)(?<![\w-])([\w-]+)\1\s*[:=]\s*/gi;
|
|
16
47
|
|
|
17
48
|
/** Matches credential field names without treating benign prefixes such as `author` as `auth`. */
|
|
18
49
|
export function isSensitiveFixtureKey(key: string): boolean {
|
|
@@ -23,7 +54,8 @@ export function isSensitiveFixtureKey(key: string): boolean {
|
|
|
23
54
|
/^(?:authorization|authentication|auth|bearer|cookie|credential|password|passwd|privatekey|secret|session|sessionid|token)$/.test(
|
|
24
55
|
candidate,
|
|
25
56
|
) ||
|
|
26
|
-
|
|
57
|
+
// Unanchored at the start so vendor-prefixed headers (`x-api-key`) match too.
|
|
58
|
+
/(?:api|client|service|access|consumer)(?:key|secret|token)$/.test(candidate) ||
|
|
27
59
|
/(?:authorization|credential|password|passwd|privatekey|secret|sessionid|token)$/.test(
|
|
28
60
|
candidate,
|
|
29
61
|
),
|
|
@@ -71,7 +103,7 @@ export function sanitizeFixtureString(value: string): string {
|
|
|
71
103
|
retainedUrls.push(isCredentialBearingUrl(url) ? sanitizeUrlForLogs(url) : url) - 1;
|
|
72
104
|
return `APIFUSEURL${index}X`;
|
|
73
105
|
});
|
|
74
|
-
sanitized =
|
|
106
|
+
sanitized = redactCredentialSpans(sanitized, [credentialHeaderSpans, sensitiveAssignmentSpans]);
|
|
75
107
|
sanitized = sanitized.replace(OPAQUE_TOKEN_RUN, (candidate) =>
|
|
76
108
|
isSensitiveFixtureValue(candidate) ? REDACTED_FIXTURE_VALUE : candidate,
|
|
77
109
|
);
|
|
@@ -162,9 +194,12 @@ export function sanitizeDiagnosticText(value: string): string {
|
|
|
162
194
|
const index = retainedUrls.push(sanitizeUrlForLogs(url)) - 1;
|
|
163
195
|
return `${DIAGNOSTIC_URL_SENTINEL_DELIMITER}APIFUSE_URL${index}${DIAGNOSTIC_URL_SENTINEL_DELIMITER}`;
|
|
164
196
|
})
|
|
165
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED_FIXTURE_VALUE}`)
|
|
166
197
|
.replace(EMAIL_ADDRESS_RUN, REDACTED_FIXTURE_VALUE);
|
|
167
|
-
sanitized =
|
|
198
|
+
sanitized = redactCredentialSpans(sanitized, [
|
|
199
|
+
credentialHeaderSpans,
|
|
200
|
+
keylessCredentialSpans,
|
|
201
|
+
sensitiveAssignmentSpans,
|
|
202
|
+
]);
|
|
168
203
|
sanitized = sanitized.replace(OPAQUE_TOKEN_RUN, (candidate, offset: number, source: string) => {
|
|
169
204
|
if (/^(?:request|trace|correlation)[-_]?id[:=]/i.test(candidate)) return candidate;
|
|
170
205
|
const prefix = source.slice(Math.max(0, offset - 32), offset);
|
|
@@ -178,19 +213,142 @@ export function sanitizeDiagnosticText(value: string): string {
|
|
|
178
213
|
return encodeDiagnosticControls(sanitized);
|
|
179
214
|
}
|
|
180
215
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
type CredentialSpan = readonly [start: number, end: number];
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Every credential rule scans the same unmodified text and reports spans; the output redacts
|
|
220
|
+
* their union. No rule's rewrite can hide a key or a scheme word from another rule, so adding a
|
|
221
|
+
* rule can only redact more.
|
|
222
|
+
*/
|
|
223
|
+
function redactCredentialSpans(
|
|
224
|
+
value: string,
|
|
225
|
+
rules: readonly ((value: string) => Iterable<CredentialSpan>)[],
|
|
226
|
+
): string {
|
|
227
|
+
const spans = rules
|
|
228
|
+
.flatMap((rule) => [...rule(value)])
|
|
229
|
+
.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
|
|
230
|
+
let output = "";
|
|
231
|
+
let cursor = 0;
|
|
232
|
+
let open: [number, number] | undefined;
|
|
233
|
+
const close = (span: [number, number]) => {
|
|
234
|
+
output += value.slice(cursor, span[0]) + REDACTED_FIXTURE_VALUE;
|
|
235
|
+
cursor = span[1];
|
|
236
|
+
};
|
|
237
|
+
for (const [start, end] of spans) {
|
|
238
|
+
if (open && start <= open[1]) {
|
|
239
|
+
open[1] = Math.max(open[1], end);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (open) close(open);
|
|
243
|
+
open = [start, end];
|
|
244
|
+
}
|
|
245
|
+
if (open) close(open);
|
|
246
|
+
return output + value.slice(cursor);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The scheme and token of a credential header value, as one span. */
|
|
250
|
+
function* credentialHeaderSpans(value: string): Iterable<CredentialSpan> {
|
|
251
|
+
for (const match of value.matchAll(CREDENTIALS_HEADER_ASSIGNMENT)) {
|
|
252
|
+
yield [match.index + (match[1] as string).length, match.index + match[0].length];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Tokens after keyless `Bearer`/`Negotiate`/`NTLM`, and `Basic` tokens that decode to `user-id:password`. */
|
|
257
|
+
function* keylessCredentialSpans(value: string): Iterable<CredentialSpan> {
|
|
258
|
+
const opaqueEnds = runEnds(value, OPAQUE_TOKEN_CHARACTER);
|
|
259
|
+
for (const scheme of TOKEN68_SCHEMES) {
|
|
260
|
+
for (const match of value.matchAll(scheme)) {
|
|
261
|
+
const start = match.index + match[0].length;
|
|
262
|
+
if (opaqueEnds[start] > start) yield [start, opaqueEnds[start]];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const base64Ends = runEnds(value, BASE64_CHARACTER);
|
|
266
|
+
for (const match of value.matchAll(BASIC_SCHEME)) {
|
|
267
|
+
const start = match.index + match[0].length;
|
|
268
|
+
const payloadEnd = base64Ends[start];
|
|
269
|
+
let end = payloadEnd;
|
|
270
|
+
while (end < payloadEnd + 2 && value[end] === "=") end++;
|
|
271
|
+
// The base64 token must be the whole opaque run, not the prefix of a longer token.
|
|
272
|
+
if (payloadEnd === start || end !== opaqueEnds[start]) continue;
|
|
273
|
+
if (Buffer.from(value.slice(start, end), "base64").includes(0x3a)) yield [start, end];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Values of credential-named keys; quotes around a quoted value are kept. Every key is visited,
|
|
279
|
+
* including keys inside another key's value (`auth: {"password": "x"}`), and each value's extent
|
|
280
|
+
* is read from precomputed tables, so the scan stays linear in the text length.
|
|
281
|
+
*/
|
|
282
|
+
function* sensitiveAssignmentSpans(value: string): Iterable<CredentialSpan> {
|
|
283
|
+
let extents: AssignmentValueExtents | undefined;
|
|
284
|
+
for (const key of value.matchAll(ASSIGNMENT_KEY)) {
|
|
285
|
+
const name = key[2] as string;
|
|
286
|
+
if (!isSensitiveFixtureKey(name) && name.toLowerCase() !== "key") continue;
|
|
287
|
+
extents ??= assignmentValueExtents(value);
|
|
288
|
+
const start = key.index + key[0].length;
|
|
289
|
+
const quote = value[start];
|
|
290
|
+
const closing =
|
|
291
|
+
quote === '"'
|
|
292
|
+
? extents.doubleQuoteClose[start + 1]
|
|
293
|
+
: quote === "'"
|
|
294
|
+
? extents.singleQuoteClose[start + 1]
|
|
295
|
+
: -1;
|
|
296
|
+
if (closing !== -1) {
|
|
297
|
+
yield [start + 1, closing];
|
|
298
|
+
} else if (extents.unquotedEnds[start] > start) {
|
|
299
|
+
yield [start, extents.unquotedEnds[start]];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type AssignmentValueExtents = {
|
|
305
|
+
/** End of the unquoted value (`[^\s,;&]+`) starting at each index. */
|
|
306
|
+
readonly unquotedEnds: Int32Array;
|
|
307
|
+
/** Index of the closing `"` when a `"(?:\\.|[^"\\])*"` body is read from each index, or -1. */
|
|
308
|
+
readonly doubleQuoteClose: Int32Array;
|
|
309
|
+
readonly singleQuoteClose: Int32Array;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function assignmentValueExtents(value: string): AssignmentValueExtents {
|
|
313
|
+
const unquotedEnds = new Int32Array(value.length + 1);
|
|
314
|
+
unquotedEnds[value.length] = value.length;
|
|
315
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
316
|
+
unquotedEnds[index] = ASSIGNMENT_VALUE_DELIMITER.test(value[index] as string)
|
|
317
|
+
? index
|
|
318
|
+
: unquotedEnds[index + 1];
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
unquotedEnds,
|
|
322
|
+
doubleQuoteClose: closingQuotes(value, '"'),
|
|
323
|
+
singleQuoteClose: closingQuotes(value, "'"),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Right-to-left: reading a quoted body from index i either closes at a quote or skips an escape pair. */
|
|
328
|
+
function closingQuotes(value: string, quote: string): Int32Array {
|
|
329
|
+
const closing = new Int32Array(value.length + 2).fill(-1);
|
|
330
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
331
|
+
const character = value[index];
|
|
332
|
+
closing[index] =
|
|
333
|
+
character === quote
|
|
334
|
+
? index
|
|
335
|
+
: character === "\\"
|
|
336
|
+
? /[^\n\r\u2028\u2029]/.test(value[index + 1] ?? "\n")
|
|
337
|
+
? (closing[index + 2] as number)
|
|
338
|
+
: -1
|
|
339
|
+
: (closing[index + 1] as number);
|
|
340
|
+
}
|
|
341
|
+
return closing;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** For each index, the end of the run of `character` starting there, in one right-to-left pass. */
|
|
345
|
+
function runEnds(value: string, character: RegExp): Int32Array {
|
|
346
|
+
const ends = new Int32Array(value.length + 1);
|
|
347
|
+
ends[value.length] = value.length;
|
|
348
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
349
|
+
ends[index] = character.test(value[index] as string) ? (ends[index + 1] as number) : index;
|
|
350
|
+
}
|
|
351
|
+
return ends;
|
|
194
352
|
}
|
|
195
353
|
|
|
196
354
|
function isCredentialBearingUrl(value: string): boolean {
|