@apifuse/provider-sdk 2.2.0-beta.44 → 2.2.0-beta.45
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/CHANGELOG.md +4 -0
- package/README.md +2 -1
- package/bin/apifuse-check.ts +14 -5
- package/dist/declaration-validation.d.ts +2 -1
- package/dist/declaration-validation.js +59 -0
- package/dist/define.d.ts +4 -0
- package/dist/lint.d.ts +15 -0
- package/dist/lint.js +171 -2
- package/dist/runtime/insights.js +2 -2
- package/dist/runtime/stealth.d.ts +1 -1
- package/dist/runtime/stealth.js +14 -5
- package/dist/server/serve-implementation.js +4 -4
- package/dist/stealth/profiles.d.ts +4 -0
- package/dist/stealth/profiles.js +118 -15
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/src/declaration-validation.ts +65 -1
- package/src/define.ts +4 -0
- package/src/lint.ts +240 -2
- package/src/runtime/insights.ts +2 -2
- package/src/runtime/stealth.ts +17 -5
- package/src/server/serve-implementation.ts +4 -4
- package/src/stealth/profiles.ts +135 -16
- package/src/types.ts +4 -0
package/dist/stealth/profiles.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { SDKError } from "../errors.js";
|
|
3
|
+
const requireModule = createRequire(import.meta.url);
|
|
4
|
+
let wreqProfileApi;
|
|
5
|
+
function getWreqProfileApi() {
|
|
6
|
+
wreqProfileApi ??= requireModule("wreq-js");
|
|
7
|
+
return wreqProfileApi;
|
|
8
|
+
}
|
|
2
9
|
const CHROMIUM_HEADER_ORDER = [
|
|
3
10
|
":method",
|
|
4
11
|
":authority",
|
|
@@ -65,6 +72,50 @@ const SAFARI_H2_SETTINGS = {
|
|
|
65
72
|
const CHROMIUM_JA3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513-65037,29-23-24,0";
|
|
66
73
|
const FIREFOX_JA3 = "771,4865-4867-4866-49195-49199-52393-52392-49196-49200-49162-49172-156-157-47-53,0-23-65281-10-11-35-16-5-13-28-27-43-45-51,29-23-24-25,0";
|
|
67
74
|
const SAFARI_JA3 = "771,4865-4866-4867-49196-49195-52393-49200-49199-49188-49192-159-158-107-103-57-51-157-156-61-60-53-47-255,0-23-65281-10-11-16-5-13-18-51-45-43-27,29-23-24-25,0";
|
|
75
|
+
/**
|
|
76
|
+
* The newest Chromium build wreq-js can emulate, resolved on first profile use.
|
|
77
|
+
*
|
|
78
|
+
* A literal version here rots: it went stale twice (146 was six releases behind
|
|
79
|
+
* stable when an upstream integrity analyzer flagged it), and a fingerprint that
|
|
80
|
+
* advertises an old Chrome is exactly what bot managers score against. wreq-js
|
|
81
|
+
* already ships the profile table, so the newest entry is derived from it rather
|
|
82
|
+
* than restated. `resolveProfile("chrome")` is deliberately NOT used because its
|
|
83
|
+
* conservative default can lag newer entries in that table.
|
|
84
|
+
*
|
|
85
|
+
* package.json pins wreq-js exactly on purpose. Even a semver-minor wreq-js update
|
|
86
|
+
* can change the profile table, emitted headers, native bindings, and therefore
|
|
87
|
+
* the wire fingerprint inherited by every provider. Upgrade that exact version
|
|
88
|
+
* only in a dedicated change with profile-parity and packed-native verification.
|
|
89
|
+
*/
|
|
90
|
+
function resolveLatestChromiumProfile() {
|
|
91
|
+
const { getProfiles } = getWreqProfileApi();
|
|
92
|
+
let newest;
|
|
93
|
+
for (const name of getProfiles()) {
|
|
94
|
+
const match = /^chrome_(\d+)$/.exec(name);
|
|
95
|
+
if (!match)
|
|
96
|
+
continue;
|
|
97
|
+
const version = Number(match[1]);
|
|
98
|
+
if (!newest || version > newest.version)
|
|
99
|
+
newest = { name, version };
|
|
100
|
+
}
|
|
101
|
+
if (!newest) {
|
|
102
|
+
throw new SDKError("wreq-js exposes no chrome_<version> emulation profile.", {
|
|
103
|
+
code: "STEALTH_PROFILE_UNAVAILABLE",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { wreqName: newest.name, version: `${newest.version}.0.0.0` };
|
|
107
|
+
}
|
|
108
|
+
/** The user agent wreq-js itself emits for that profile, so the two never disagree. */
|
|
109
|
+
function chromiumUserAgent(latest) {
|
|
110
|
+
const { getEmulationHeaders } = getWreqProfileApi();
|
|
111
|
+
for (const [name, value] of getEmulationHeaders(latest.wreqName, "macos")) {
|
|
112
|
+
if (String(name).toLowerCase() === "user-agent")
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
throw new SDKError(`wreq-js profile ${latest.wreqName} exposes no user-agent header.`, {
|
|
116
|
+
code: "STEALTH_PROFILE_UNAVAILABLE",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
68
119
|
function createProfile(name, definition) {
|
|
69
120
|
return {
|
|
70
121
|
name,
|
|
@@ -120,10 +171,21 @@ export function generateLayer2Headers(profile) {
|
|
|
120
171
|
}
|
|
121
172
|
return headers;
|
|
122
173
|
}
|
|
123
|
-
const
|
|
124
|
-
"
|
|
174
|
+
const STATIC_STEALTH_PROFILE_ALIASES = {
|
|
175
|
+
"firefox-desktop": "firefox-147",
|
|
176
|
+
"safari-desktop": "safari-17",
|
|
177
|
+
"safari-mobile": "ios-safari-26",
|
|
125
178
|
};
|
|
126
|
-
const
|
|
179
|
+
const PUBLIC_STEALTH_PROFILE_NAMES = [
|
|
180
|
+
"chrome-desktop",
|
|
181
|
+
"firefox-desktop",
|
|
182
|
+
"safari-desktop",
|
|
183
|
+
"safari-mobile",
|
|
184
|
+
"generic-desktop",
|
|
185
|
+
"generic-mobile",
|
|
186
|
+
];
|
|
187
|
+
const PUBLIC_STEALTH_PROFILE_NAME_SET = new Set(PUBLIC_STEALTH_PROFILE_NAMES);
|
|
188
|
+
const STATIC_STEALTH_PROFILES = {
|
|
127
189
|
"chrome-146": createProfile("chrome-146", {
|
|
128
190
|
platform: "macos",
|
|
129
191
|
version: "146.0.0.0",
|
|
@@ -223,15 +285,6 @@ const STEALTH_PROFILES = {
|
|
|
223
285
|
h2Settings: SAFARI_H2_SETTINGS,
|
|
224
286
|
headerOrder: SAFARI_HEADER_ORDER,
|
|
225
287
|
}),
|
|
226
|
-
"generic-desktop": createProfile("generic-desktop", {
|
|
227
|
-
platform: "macos",
|
|
228
|
-
version: "146.0.0.0",
|
|
229
|
-
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
230
|
-
tlsClientIdentifier: "chrome_146",
|
|
231
|
-
ja3: CHROMIUM_JA3,
|
|
232
|
-
h2Settings: CHROMIUM_H2_SETTINGS,
|
|
233
|
-
headerOrder: CHROMIUM_HEADER_ORDER,
|
|
234
|
-
}),
|
|
235
288
|
"generic-mobile": createProfile("generic-mobile", {
|
|
236
289
|
platform: "ios",
|
|
237
290
|
version: "26.0",
|
|
@@ -242,9 +295,38 @@ const STEALTH_PROFILES = {
|
|
|
242
295
|
headerOrder: SAFARI_HEADER_ORDER,
|
|
243
296
|
}),
|
|
244
297
|
};
|
|
298
|
+
let stealthProfileCatalog;
|
|
299
|
+
function getStealthProfileCatalog() {
|
|
300
|
+
if (stealthProfileCatalog)
|
|
301
|
+
return stealthProfileCatalog;
|
|
302
|
+
const latest = resolveLatestChromiumProfile();
|
|
303
|
+
const currentName = `chrome-${latest.version.split(".")[0]}`;
|
|
304
|
+
const currentProfile = createProfile(currentName, {
|
|
305
|
+
platform: "macos",
|
|
306
|
+
version: latest.version,
|
|
307
|
+
userAgent: chromiumUserAgent(latest),
|
|
308
|
+
tlsClientIdentifier: latest.wreqName,
|
|
309
|
+
ja3: CHROMIUM_JA3,
|
|
310
|
+
h2Settings: CHROMIUM_H2_SETTINGS,
|
|
311
|
+
headerOrder: CHROMIUM_HEADER_ORDER,
|
|
312
|
+
});
|
|
313
|
+
stealthProfileCatalog = {
|
|
314
|
+
aliases: {
|
|
315
|
+
"chrome-desktop": currentName,
|
|
316
|
+
...STATIC_STEALTH_PROFILE_ALIASES,
|
|
317
|
+
},
|
|
318
|
+
profiles: {
|
|
319
|
+
[currentName]: currentProfile,
|
|
320
|
+
...STATIC_STEALTH_PROFILES,
|
|
321
|
+
"generic-desktop": createProfile("generic-desktop", currentProfile),
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
return stealthProfileCatalog;
|
|
325
|
+
}
|
|
245
326
|
export function getStealthProfile(name) {
|
|
246
|
-
const
|
|
247
|
-
const
|
|
327
|
+
const catalog = getStealthProfileCatalog();
|
|
328
|
+
const canonicalName = catalog.aliases[name] ?? name;
|
|
329
|
+
const profile = catalog.profiles[canonicalName];
|
|
248
330
|
if (!profile) {
|
|
249
331
|
throw new SDKError(`Unknown stealth profile: ${name}`);
|
|
250
332
|
}
|
|
@@ -254,6 +336,27 @@ export function getStealthProfile(name) {
|
|
|
254
336
|
headerOrder: profile.headerOrder ? [...profile.headerOrder] : undefined,
|
|
255
337
|
};
|
|
256
338
|
}
|
|
339
|
+
/** Returns the intent alias that replaces a registered version-pinned profile. */
|
|
340
|
+
export function getStealthProfileIntentAlias(name) {
|
|
341
|
+
if (PUBLIC_STEALTH_PROFILE_NAME_SET.has(name))
|
|
342
|
+
return undefined;
|
|
343
|
+
if (/^(?:chrome|chromium|edge)[-_]\d/i.test(name)) {
|
|
344
|
+
return "chrome-desktop";
|
|
345
|
+
}
|
|
346
|
+
if (/^firefox[-_]\d/i.test(name))
|
|
347
|
+
return "firefox-desktop";
|
|
348
|
+
if (/^(?:ios[-_]safari|safari[-_](?:ios|ipad))[-_]\d/i.test(name)) {
|
|
349
|
+
return "safari-mobile";
|
|
350
|
+
}
|
|
351
|
+
if (/^safari[-_]\d/i.test(name))
|
|
352
|
+
return "safari-desktop";
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
/** Internal compatibility catalog used by transport-parity tests. */
|
|
356
|
+
export function listRegisteredStealthProfiles() {
|
|
357
|
+
const catalog = getStealthProfileCatalog();
|
|
358
|
+
return [...Object.keys(catalog.profiles), ...Object.keys(catalog.aliases)];
|
|
359
|
+
}
|
|
257
360
|
export function listStealthProfiles() {
|
|
258
|
-
return [...
|
|
361
|
+
return [...PUBLIC_STEALTH_PROFILE_NAMES];
|
|
259
362
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -956,6 +956,10 @@ export interface ProviderMeta {
|
|
|
956
956
|
publicProfile?: ProviderPublicProfile;
|
|
957
957
|
contract?: {
|
|
958
958
|
publicSchemaFieldNames?: "normalized";
|
|
959
|
+
readonly pinnedWireFieldPaths?: readonly {
|
|
960
|
+
readonly path: string;
|
|
961
|
+
readonly reason: string;
|
|
962
|
+
}[];
|
|
959
963
|
};
|
|
960
964
|
}
|
|
961
965
|
export type RequestParamPrimitive = string | number | boolean | null | undefined;
|
package/package.json
CHANGED
|
@@ -21,6 +21,7 @@ export const DECLARATION_RULE_IDS = {
|
|
|
21
21
|
proxyNoMixedVendors: "proxy-no-mixed-vendors",
|
|
22
22
|
proxySmartproxyGeo: "proxy-smartproxy-country-only",
|
|
23
23
|
operationUpstreamProxy: "operation-upstream-proxy-unsupported",
|
|
24
|
+
pinnedWireFieldPathValid: "public-schema-pinned-wire-field-valid",
|
|
24
25
|
} as const;
|
|
25
26
|
|
|
26
27
|
export type DeclarationRuleId =
|
|
@@ -53,13 +54,14 @@ export function declarationInvalidError(
|
|
|
53
54
|
export function validateFailClosedDeclaration(provider: ProviderDefinition): void {
|
|
54
55
|
const violations: DeclarationViolation[] = [];
|
|
55
56
|
validateHealthDeclaration(provider, violations);
|
|
57
|
+
validatePinnedWireFieldPaths(provider, violations);
|
|
56
58
|
validateSchemaDeclaration(provider, violations);
|
|
57
59
|
validateProxyDeclaration(provider, violations);
|
|
58
60
|
validateOperationDeclaration(provider, violations);
|
|
59
61
|
if (violations.length > 0) throw declarationInvalidError(violations);
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
type ProviderDeclarationRulesInput = Pick<ProviderDefinition, "healthJourneys" | "proxy">;
|
|
64
|
+
type ProviderDeclarationRulesInput = Pick<ProviderDefinition, "healthJourneys" | "meta" | "proxy">;
|
|
63
65
|
type OperationDeclarationRulesInput = Pick<ProviderDefinition, "operations">;
|
|
64
66
|
|
|
65
67
|
/** Enforces fail-closed rules that only depend on the provider declaration. */
|
|
@@ -85,9 +87,71 @@ function collectProviderDeclarationViolations(
|
|
|
85
87
|
violations: DeclarationViolation[],
|
|
86
88
|
): void {
|
|
87
89
|
validateHealthDeclaration(provider, violations);
|
|
90
|
+
validatePinnedWireFieldPaths(provider, violations);
|
|
88
91
|
validateProxyDeclaration(provider, violations);
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
function validatePinnedWireFieldPaths(
|
|
95
|
+
provider: ProviderDeclarationRulesInput,
|
|
96
|
+
violations: DeclarationViolation[],
|
|
97
|
+
): void {
|
|
98
|
+
const pins = provider.meta?.contract?.pinnedWireFieldPaths;
|
|
99
|
+
if (pins === undefined) return;
|
|
100
|
+
|
|
101
|
+
if (!Array.isArray(pins)) {
|
|
102
|
+
violations.push({
|
|
103
|
+
ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
|
|
104
|
+
path: "meta.contract.pinnedWireFieldPaths",
|
|
105
|
+
message: "pinned wire field paths must be an array.",
|
|
106
|
+
fix: "Declare a readonly array of { path, reason } entries.",
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const seenPaths = new Set<string>();
|
|
112
|
+
for (const [index, pin] of pins.entries()) {
|
|
113
|
+
const entryPath = `meta.contract.pinnedWireFieldPaths[${index}]`;
|
|
114
|
+
if (!pin || typeof pin !== "object" || Array.isArray(pin)) {
|
|
115
|
+
violations.push({
|
|
116
|
+
ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
|
|
117
|
+
path: entryPath,
|
|
118
|
+
message: "each pinned wire field path must be an object.",
|
|
119
|
+
fix: `Replace ${entryPath} with { path: "operations.<operation-id>.<field-path>", reason: "<non-empty reason>" }.`,
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const path = "path" in pin ? pin.path : undefined;
|
|
125
|
+
const reason = "reason" in pin ? pin.reason : undefined;
|
|
126
|
+
if (typeof path !== "string" || path.trim().length === 0) {
|
|
127
|
+
violations.push({
|
|
128
|
+
ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
|
|
129
|
+
path: `${entryPath}.path`,
|
|
130
|
+
message: "path must be a non-empty string.",
|
|
131
|
+
fix: `Set ${entryPath}.path to the exact public-schema-upstream-field diagnostic path.`,
|
|
132
|
+
});
|
|
133
|
+
} else if (seenPaths.has(path)) {
|
|
134
|
+
violations.push({
|
|
135
|
+
ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
|
|
136
|
+
path: `${entryPath}.path`,
|
|
137
|
+
message: `duplicate pinned wire field path ${JSON.stringify(path)}.`,
|
|
138
|
+
fix: `Keep exactly one declaration for ${JSON.stringify(path)}.`,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
seenPaths.add(path);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof reason !== "string" || reason.trim().length === 0) {
|
|
145
|
+
violations.push({
|
|
146
|
+
ruleId: DECLARATION_RULE_IDS.pinnedWireFieldPathValid,
|
|
147
|
+
path: `${entryPath}.reason`,
|
|
148
|
+
message: "reason must be a non-empty string.",
|
|
149
|
+
fix: `Document why renaming the exact wire field would break the upstream contract.`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
91
155
|
function collectOperationDeclarationViolations(
|
|
92
156
|
provider: OperationDeclarationRulesInput,
|
|
93
157
|
violations: DeclarationViolation[],
|
package/src/define.ts
CHANGED
|
@@ -638,6 +638,10 @@ export interface ProviderDeclaration {
|
|
|
638
638
|
implementationProfile?: ProviderImplementationProfile;
|
|
639
639
|
contract?: {
|
|
640
640
|
publicSchemaFieldNames?: "normalized";
|
|
641
|
+
readonly pinnedWireFieldPaths?: readonly {
|
|
642
|
+
readonly path: string;
|
|
643
|
+
readonly reason: string;
|
|
644
|
+
}[];
|
|
641
645
|
};
|
|
642
646
|
};
|
|
643
647
|
healthMonitor?: ProviderHealthMonitorConfig;
|
package/src/lint.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
1
3
|
import type { ZodType } from "zod";
|
|
2
4
|
|
|
3
5
|
import {
|
|
@@ -7,6 +9,17 @@ import {
|
|
|
7
9
|
import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
|
|
8
10
|
import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
|
|
9
11
|
|
|
12
|
+
const requireModule = createRequire(import.meta.url);
|
|
13
|
+
// `typeof import(...)` keeps the type without emitting a static import: the
|
|
14
|
+
// typescript package is a CLI-only dependency and src/lint.ts is production
|
|
15
|
+
// runtime, which the typescript-import-boundary test enforces.
|
|
16
|
+
let typeScriptModule: typeof import("typescript") | undefined;
|
|
17
|
+
|
|
18
|
+
function getTypeScript(): typeof import("typescript") {
|
|
19
|
+
typeScriptModule ??= requireModule("typescript") as typeof import("typescript");
|
|
20
|
+
return typeScriptModule;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
type AuthModeLike =
|
|
11
24
|
| "none"
|
|
12
25
|
| "platform-managed"
|
|
@@ -120,6 +133,10 @@ function isAuthLifecycleOperationId(operationId: string, authMode: string): bool
|
|
|
120
133
|
|
|
121
134
|
type ProviderContractMetaLike = {
|
|
122
135
|
publicSchemaFieldNames?: "normalized";
|
|
136
|
+
pinnedWireFieldPaths?: readonly {
|
|
137
|
+
readonly path: string;
|
|
138
|
+
readonly reason: string;
|
|
139
|
+
}[];
|
|
123
140
|
};
|
|
124
141
|
|
|
125
142
|
type SchemaLike = ZodType & {
|
|
@@ -153,6 +170,17 @@ type ProviderLintOptions = {
|
|
|
153
170
|
mode?: ProviderLintMode;
|
|
154
171
|
};
|
|
155
172
|
|
|
173
|
+
export interface ProviderLintInformation {
|
|
174
|
+
rule: string;
|
|
175
|
+
message: string;
|
|
176
|
+
field?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ProviderLintResult {
|
|
180
|
+
diagnostics: LintDiagnostic[];
|
|
181
|
+
information: ProviderLintInformation[];
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
type ProviderSourceLike = {
|
|
157
185
|
authFlowSource?: string;
|
|
158
186
|
providerSourceFiles?: Record<string, string>;
|
|
@@ -894,6 +922,146 @@ function lintSelfHostedBrowserPatterns(
|
|
|
894
922
|
const THROWN_ERROR_CONSTRUCTION_PATTERN = /new\s+(?:ProviderError|ValidationError)\s*\(/g;
|
|
895
923
|
|
|
896
924
|
const TEST_SOURCE_FILE_PATTERN = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
925
|
+
const RECORDED_FIXTURE_SOURCE_FILE_PATTERN =
|
|
926
|
+
/(?:^|\/)__fixtures__(?:\/|$)|(?:^|\/)__tests__\/fixtures(?:\/|$)/;
|
|
927
|
+
const JAVASCRIPT_SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/;
|
|
928
|
+
const VERSIONED_PROFILE_LITERAL_PATTERN =
|
|
929
|
+
/\b(?:chrome|chromium|firefox|safari|edge|opera|ios[-_]safari)[-_]\d+(?:[._-]\d+)*(?=$|[^A-Za-z0-9])/i;
|
|
930
|
+
const VERSIONED_USER_AGENT_PATTERN = /\b(?:Chrome|CriOS|Firefox|FxiOS|EdgA?|OPR)\/\d+(?:\.\d+)*/i;
|
|
931
|
+
const VERSIONED_SAFARI_USER_AGENT_PATTERN = /\bVersion\/(\d+(?:\.\d+)*)(?=[\s\S]*\bSafari\/\d)/i;
|
|
932
|
+
const VERSIONED_CLIENT_HINT_PATTERN = /(?:^|[;,\s])v\s*=\s*["']?\d+/i;
|
|
933
|
+
|
|
934
|
+
type BrowserVersionLiteralKind = "profile" | "user-agent" | "sec-ch-ua";
|
|
935
|
+
|
|
936
|
+
type BrowserVersionLiteralFinding = {
|
|
937
|
+
kind: BrowserVersionLiteralKind;
|
|
938
|
+
literal: string;
|
|
939
|
+
position: number;
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
function staticStringText(node: import("typescript").Node | undefined): string | undefined {
|
|
943
|
+
if (!node) return undefined;
|
|
944
|
+
const ts = getTypeScript();
|
|
945
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function staticPropertyName(node: import("typescript").PropertyName): string | undefined {
|
|
950
|
+
const ts = getTypeScript();
|
|
951
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) {
|
|
952
|
+
return node.text;
|
|
953
|
+
}
|
|
954
|
+
if (ts.isComputedPropertyName(node)) return staticStringText(node.expression);
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function isSecChUaHeaderName(value: string | undefined): boolean {
|
|
959
|
+
return value?.toLowerCase() === "sec-ch-ua";
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function collectBrowserVersionLiteralFindings(source: string): BrowserVersionLiteralFinding[] {
|
|
963
|
+
const ts = getTypeScript();
|
|
964
|
+
const sourceFile = ts.createSourceFile(
|
|
965
|
+
"provider-source.ts",
|
|
966
|
+
source,
|
|
967
|
+
ts.ScriptTarget.Latest,
|
|
968
|
+
true,
|
|
969
|
+
ts.ScriptKind.TSX,
|
|
970
|
+
);
|
|
971
|
+
const findings: BrowserVersionLiteralFinding[] = [];
|
|
972
|
+
const seen = new Set<string>();
|
|
973
|
+
|
|
974
|
+
const addFinding = (kind: BrowserVersionLiteralKind, literal: string, position: number) => {
|
|
975
|
+
const key = `${kind}:${position}:${literal}`;
|
|
976
|
+
if (seen.has(key)) return;
|
|
977
|
+
seen.add(key);
|
|
978
|
+
findings.push({ kind, literal, position });
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const inspectLiteral = (text: string, position: number) => {
|
|
982
|
+
const profile = text.match(VERSIONED_PROFILE_LITERAL_PATTERN)?.[0];
|
|
983
|
+
if (profile) addFinding("profile", profile, position);
|
|
984
|
+
|
|
985
|
+
const userAgent =
|
|
986
|
+
text.match(VERSIONED_USER_AGENT_PATTERN)?.[0] ??
|
|
987
|
+
text.match(VERSIONED_SAFARI_USER_AGENT_PATTERN)?.[0];
|
|
988
|
+
if (userAgent) addFinding("user-agent", userAgent, position);
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const inspectSecChUaValue = (node: import("typescript").Node | undefined) => {
|
|
992
|
+
const text = staticStringText(node);
|
|
993
|
+
if (text && VERSIONED_CLIENT_HINT_PATTERN.test(text)) {
|
|
994
|
+
addFinding("sec-ch-ua", "sec-ch-ua", node!.getStart(sourceFile));
|
|
995
|
+
}
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const visit = (node: import("typescript").Node) => {
|
|
999
|
+
const text = staticStringText(node);
|
|
1000
|
+
if (text !== undefined) inspectLiteral(text, node.getStart(sourceFile));
|
|
1001
|
+
|
|
1002
|
+
if (ts.isPropertyAssignment(node) && isSecChUaHeaderName(staticPropertyName(node.name))) {
|
|
1003
|
+
inspectSecChUaValue(node.initializer);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (ts.isCallExpression(node) && isSecChUaHeaderName(staticStringText(node.arguments[0]))) {
|
|
1007
|
+
inspectSecChUaValue(node.arguments[1]);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (
|
|
1011
|
+
ts.isArrayLiteralExpression(node) &&
|
|
1012
|
+
isSecChUaHeaderName(staticStringText(node.elements[0]))
|
|
1013
|
+
) {
|
|
1014
|
+
inspectSecChUaValue(node.elements[1]);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
ts.forEachChild(node, visit);
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
visit(sourceFile);
|
|
1021
|
+
return findings.sort((left, right) => left.position - right.position);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function browserVersionLiteralMessage(finding: BrowserVersionLiteralFinding): string {
|
|
1025
|
+
switch (finding.kind) {
|
|
1026
|
+
case "profile":
|
|
1027
|
+
return `Hardcoded stealth profile "${finding.literal}" pins a browser version and will rot. Use the matching intent alias instead: "chrome-desktop", "firefox-desktop", "safari-desktop", or "safari-mobile".`;
|
|
1028
|
+
case "user-agent":
|
|
1029
|
+
return `Hardcoded User-Agent browser version "${finding.literal}" can disagree with the stealth TLS fingerprint. Remove the literal and derive it from the matching intent profile, for example getStealthProfile("chrome-desktop").userAgent.`;
|
|
1030
|
+
case "sec-ch-ua":
|
|
1031
|
+
return 'Hardcoded sec-ch-ua versions can disagree with the stealth TLS fingerprint. Remove the literal and let ctx.stealth generate client hints from an intent profile such as "chrome-desktop"; derive any explicit User-Agent with getStealthProfile("chrome-desktop").userAgent.';
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function lintBrowserVersionLiterals(provider: ProviderSourceLike): LintDiagnostic[] {
|
|
1036
|
+
const sources: Array<{ field: string; source: string }> = [];
|
|
1037
|
+
const sourceFiles = Object.entries(provider.providerSourceFiles ?? {}).filter(
|
|
1038
|
+
([filePath]) =>
|
|
1039
|
+
JAVASCRIPT_SOURCE_FILE_PATTERN.test(filePath) &&
|
|
1040
|
+
!TEST_SOURCE_FILE_PATTERN.test(filePath) &&
|
|
1041
|
+
!RECORDED_FIXTURE_SOURCE_FILE_PATTERN.test(filePath),
|
|
1042
|
+
);
|
|
1043
|
+
if (sourceFiles.length > 0) {
|
|
1044
|
+
for (const [filePath, source] of sourceFiles) {
|
|
1045
|
+
sources.push({ field: `sourceFiles.${filePath}`, source });
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
if (provider.authFlowSource)
|
|
1049
|
+
sources.push({ field: "auth.flow", source: provider.authFlowSource });
|
|
1050
|
+
for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
|
|
1051
|
+
const source = getOperationSource(operation);
|
|
1052
|
+
if (source) sources.push({ field: `operations.${operationKey}.handler`, source });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return sources.flatMap(({ field, source }) =>
|
|
1057
|
+
collectBrowserVersionLiteralFindings(source).map((finding) => ({
|
|
1058
|
+
rule: "browser-version-literal",
|
|
1059
|
+
level: "error" as const,
|
|
1060
|
+
field,
|
|
1061
|
+
message: browserVersionLiteralMessage(finding),
|
|
1062
|
+
})),
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
897
1065
|
|
|
898
1066
|
/**
|
|
899
1067
|
* Skips a string literal starting at `startIndex` (which must point at the
|
|
@@ -1298,6 +1466,67 @@ export function lintOperation(op: {
|
|
|
1298
1466
|
return diagnostics;
|
|
1299
1467
|
}
|
|
1300
1468
|
|
|
1469
|
+
function declaredPinnedWireFieldPaths(provider: {
|
|
1470
|
+
meta?: { contract?: ProviderContractMetaLike };
|
|
1471
|
+
}): readonly { readonly path: string; readonly reason: string }[] {
|
|
1472
|
+
const pins = provider.meta?.contract?.pinnedWireFieldPaths;
|
|
1473
|
+
if (!Array.isArray(pins)) return [];
|
|
1474
|
+
return pins.filter(
|
|
1475
|
+
(pin) =>
|
|
1476
|
+
pin !== null &&
|
|
1477
|
+
typeof pin === "object" &&
|
|
1478
|
+
typeof pin.path === "string" &&
|
|
1479
|
+
pin.path.trim().length > 0 &&
|
|
1480
|
+
typeof pin.reason === "string" &&
|
|
1481
|
+
pin.reason.trim().length > 0,
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function applyPinnedWireFieldPaths(
|
|
1486
|
+
provider: { meta?: { contract?: ProviderContractMetaLike } },
|
|
1487
|
+
diagnostics: readonly LintDiagnostic[],
|
|
1488
|
+
): ProviderLintResult {
|
|
1489
|
+
const pins = declaredPinnedWireFieldPaths(provider);
|
|
1490
|
+
if (pins.length === 0) {
|
|
1491
|
+
return { diagnostics: [...diagnostics], information: [] };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const pinsByPath = new Map(pins.map((pin) => [pin.path, pin]));
|
|
1495
|
+
const matchedPaths = new Set<string>();
|
|
1496
|
+
const remainingDiagnostics = diagnostics.filter((diagnostic) => {
|
|
1497
|
+
if (
|
|
1498
|
+
diagnostic.rule !== "public-schema-upstream-field" ||
|
|
1499
|
+
diagnostic.field === undefined ||
|
|
1500
|
+
!pinsByPath.has(diagnostic.field)
|
|
1501
|
+
) {
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
matchedPaths.add(diagnostic.field);
|
|
1505
|
+
return false;
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
const information: ProviderLintInformation[] = [];
|
|
1509
|
+
for (const pin of pins) {
|
|
1510
|
+
if (matchedPaths.has(pin.path)) {
|
|
1511
|
+
information.push({
|
|
1512
|
+
rule: "public-schema-pinned-wire-field",
|
|
1513
|
+
field: pin.path,
|
|
1514
|
+
message: `Suppressed exact public-schema-upstream-field diagnostic. Reason: ${pin.reason}`,
|
|
1515
|
+
});
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
remainingDiagnostics.push({
|
|
1520
|
+
rule: "public-schema-pinned-wire-field-stale",
|
|
1521
|
+
level: "error",
|
|
1522
|
+
field: pin.path,
|
|
1523
|
+
message: `Pinned wire field path ${JSON.stringify(pin.path)} matches no current public-schema-upstream-field diagnostic; remove the stale declaration.`,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return { diagnostics: remainingDiagnostics, information };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1301
1530
|
export function lintProvider(
|
|
1302
1531
|
provider: {
|
|
1303
1532
|
id?: string;
|
|
@@ -1340,6 +1569,14 @@ export function lintProvider(
|
|
|
1340
1569
|
},
|
|
1341
1570
|
options: ProviderLintOptions = {},
|
|
1342
1571
|
): LintDiagnostic[] {
|
|
1572
|
+
return lintProviderWithInformation(provider, options).diagnostics;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** Internal detailed result used by the CLI to render suppression audits. */
|
|
1576
|
+
export function lintProviderWithInformation(
|
|
1577
|
+
provider: Parameters<typeof lintProvider>[0],
|
|
1578
|
+
options: ProviderLintOptions = {},
|
|
1579
|
+
): ProviderLintResult {
|
|
1343
1580
|
const diagnostics: LintDiagnostic[] = [
|
|
1344
1581
|
...lintAllowedHosts(provider.id, provider.allowedHosts),
|
|
1345
1582
|
...lintReviewed(provider.id, provider.reviewed),
|
|
@@ -1348,6 +1585,7 @@ export function lintProvider(
|
|
|
1348
1585
|
...lintCredentialWriteUsage(provider),
|
|
1349
1586
|
...lintPlaywrightDirectImports(provider),
|
|
1350
1587
|
...lintSelfHostedBrowserPatterns(provider, options),
|
|
1588
|
+
...lintBrowserVersionLiterals(provider),
|
|
1351
1589
|
...lintUndeclaredThrownErrorCodes(provider),
|
|
1352
1590
|
];
|
|
1353
1591
|
|
|
@@ -1375,7 +1613,7 @@ export function lintProvider(
|
|
|
1375
1613
|
}
|
|
1376
1614
|
|
|
1377
1615
|
if (!provider.operations) {
|
|
1378
|
-
return diagnostics;
|
|
1616
|
+
return applyPinnedWireFieldPaths(provider, diagnostics);
|
|
1379
1617
|
}
|
|
1380
1618
|
|
|
1381
1619
|
diagnostics.push(
|
|
@@ -1411,5 +1649,5 @@ export function lintProvider(
|
|
|
1411
1649
|
),
|
|
1412
1650
|
);
|
|
1413
1651
|
|
|
1414
|
-
return diagnostics;
|
|
1652
|
+
return applyPinnedWireFieldPaths(provider, diagnostics);
|
|
1415
1653
|
}
|
package/src/runtime/insights.ts
CHANGED
|
@@ -27,7 +27,7 @@ const DNS_WARN_MS = 5;
|
|
|
27
27
|
const BROWSER_IDLE_MS = 5_000;
|
|
28
28
|
const REFRESH_WARN_RATE = 0.1;
|
|
29
29
|
|
|
30
|
-
const TLS_REUSE_FIX = `const session = ctx.stealth.createSession({ profile: 'chrome-
|
|
30
|
+
const TLS_REUSE_FIX = `const session = ctx.stealth.createSession({ profile: 'chrome-desktop' });
|
|
31
31
|
const resp = await session.fetch(url, opts);`;
|
|
32
32
|
|
|
33
33
|
const TRANSFORM_FIX = `transformResponse: (raw) => {
|
|
@@ -39,7 +39,7 @@ const LARGE_RESPONSE_FIX = `const resp = await ctx.http.get('/items', {
|
|
|
39
39
|
});`;
|
|
40
40
|
|
|
41
41
|
const DNS_FIX = `// Enable DNS caching or reuse a long-lived session per host.
|
|
42
|
-
const session = ctx.stealth.createSession({ profile: 'chrome-
|
|
42
|
+
const session = ctx.stealth.createSession({ profile: 'chrome-desktop' });
|
|
43
43
|
await session.fetch(url, opts);`;
|
|
44
44
|
|
|
45
45
|
const PROXY_FIX = `// Re-check whether this operation really needs a proxy.
|
package/src/runtime/stealth.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
vendorFromResolvedSource,
|
|
19
19
|
} from "../config/loader.js";
|
|
20
20
|
import { SDKError, TransportError } from "../errors.js";
|
|
21
|
-
import { getStealthProfile } from "../stealth/profiles.js";
|
|
21
|
+
import { getStealthProfile, getStealthProfileIntentAlias } from "../stealth/profiles.js";
|
|
22
22
|
import type {
|
|
23
23
|
HttpMethod,
|
|
24
24
|
StealthClient,
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
serializeRequestUrl,
|
|
68
68
|
} from "./request-options.js";
|
|
69
69
|
|
|
70
|
-
export const DEFAULT_PROFILE = "chrome-
|
|
70
|
+
export const DEFAULT_PROFILE = "chrome-desktop";
|
|
71
71
|
|
|
72
72
|
const MISSING_PROXY_WARNING =
|
|
73
73
|
"[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
|
|
@@ -244,7 +244,12 @@ function resolveDefaultWreqProfileMapping(): { identifier: string; os: Emulation
|
|
|
244
244
|
return { identifier, os: profile.platform };
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
|
|
247
|
+
let defaultWreqProfileMapping: ReturnType<typeof resolveDefaultWreqProfileMapping> | undefined;
|
|
248
|
+
|
|
249
|
+
function getDefaultWreqProfileMapping(): ReturnType<typeof resolveDefaultWreqProfileMapping> {
|
|
250
|
+
defaultWreqProfileMapping ??= resolveDefaultWreqProfileMapping();
|
|
251
|
+
return defaultWreqProfileMapping;
|
|
252
|
+
}
|
|
248
253
|
|
|
249
254
|
export function resolveWreqProfile(
|
|
250
255
|
profileName: string,
|
|
@@ -268,8 +273,9 @@ export function resolveWreqProfile(
|
|
|
268
273
|
// profile strings still run with the transport default instead of failing
|
|
269
274
|
// before the request starts. Removed built-in profile aliases above remain
|
|
270
275
|
// explicit errors so callers do not accidentally pin retired fingerprints.
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
const defaultMapping = getDefaultWreqProfileMapping();
|
|
277
|
+
identifier = defaultMapping.identifier;
|
|
278
|
+
os = defaultMapping.os;
|
|
273
279
|
}
|
|
274
280
|
|
|
275
281
|
const browser = closestWreqProfile(identifier, wreqProfiles);
|
|
@@ -772,6 +778,12 @@ function createSessionFetcher(
|
|
|
772
778
|
let closed = false;
|
|
773
779
|
let hasWarnedMissingProxy = false;
|
|
774
780
|
const warn = clientOptions.warn ?? console.warn;
|
|
781
|
+
const intentAlias = getStealthProfileIntentAlias(defaultProfile);
|
|
782
|
+
if (intentAlias) {
|
|
783
|
+
warn(
|
|
784
|
+
`[provider-sdk] Stealth profile "${defaultProfile}" pins a browser version and is deprecated. Use the intent profile "${intentAlias}" so TLS, headers, and User-Agent stay aligned with SDK updates; derive an explicit User-Agent with getStealthProfile("${intentAlias}").userAgent instead of hardcoding one.`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
775
787
|
const cookieJar = new StealthCookieJar([], baseUrl);
|
|
776
788
|
|
|
777
789
|
async function getClientEntry(
|