@reclaimprotocol/agent 0.1.17 → 0.1.20
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/README.md +6 -2
- package/lib/authoring/backend.d.ts +4 -0
- package/lib/authoring/tools.js +4 -1
- package/lib/cdp/capture.d.ts +2 -2
- package/lib/cdp/capture.js +25 -21
- package/lib/cdp/client.d.ts +39 -1
- package/lib/cdp/client.js +125 -1
- package/lib/consts.d.ts +7 -0
- package/lib/consts.js +15 -0
- package/lib/mcp/api/operations.gen.js +33 -9
- package/lib/mcp/instructions.js +27 -3
- package/lib/mcp/start.js +12 -8
- package/lib/mcp/tools/agent/attach.d.ts +18 -4
- package/lib/mcp/tools/agent/attach.js +142 -52
- package/lib/mcp/tools/agent/build.d.ts +1 -1
- package/lib/mcp/tools/agent/build.js +5 -3
- package/lib/mcp/tools/agent/capture.d.ts +1 -0
- package/lib/mcp/tools/agent/capture.js +8 -1
- package/lib/mcp/tools/agent/eval.js +2 -3
- package/lib/mcp/tools/agent/how-it-works.d.ts +1 -1
- package/lib/mcp/tools/agent/how-it-works.js +1 -1
- package/lib/mcp/tools/agent/publish.js +55 -1
- package/lib/mcp/tools/agent/session.d.ts +2 -2
- package/lib/mcp/tools/agent/session.js +21 -5
- package/lib/mcp/tools/agent/shared.js +4 -6
- package/lib/mcp/tools/authenticate/authenticate.d.ts +1 -1
- package/lib/mcp/tools/authenticate/authenticate.js +10 -4
- package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -0
- package/lib/mcp/tools/authenticate/builder-access.js +31 -0
- package/lib/mcp/tools/authenticate/index.d.ts +1 -0
- package/lib/mcp/tools/authenticate/index.js +1 -0
- package/lib/mcp/tools/mode/mode.d.ts +3 -2
- package/lib/mcp/tools/mode/mode.js +24 -4
- package/lib/old/login-cdp.js +11 -3
- package/lib/old/to-register.d.ts +16 -8
- package/lib/old/to-register.js +29 -21
- package/lib/old/tools/publish.js +109 -9
- package/lib/provider/draft.d.ts +20 -0
- package/lib/provider/draft.js +75 -51
- package/lib/provider/index.d.ts +1 -1
- package/lib/provider/index.js +1 -1
- package/lib/provider/injection-doc.js +4 -3
- package/lib/provider/schema.d.ts +26 -1
- package/lib/provider/secrets.d.ts +37 -0
- package/lib/provider/secrets.js +147 -6
- package/lib/provider/to-version.d.ts +26 -2
- package/lib/provider/to-version.js +29 -5
- package/package.json +2 -2
- package/skill/SKILL.builder.md +43 -95
- package/skill/SKILL.common.md +102 -0
- package/skill/SKILL.old.md +20 -74
- package/skill/builder-mode.md +4 -2
- package/skill/injection-api.md +95 -10
- package/skill/old-mode.md +5 -3
- package/src/authoring/backend.ts +4 -0
- package/src/authoring/tools.ts +4 -1
- package/src/cdp/capture.ts +24 -24
- package/src/cdp/client.ts +197 -2
- package/src/consts.ts +20 -0
- package/src/mcp/api/operations.gen.ts +33 -9
- package/src/mcp/instructions.ts +36 -3
- package/src/mcp/start.ts +13 -8
- package/src/mcp/tools/agent/attach.ts +198 -57
- package/src/mcp/tools/agent/build.ts +9 -2
- package/src/mcp/tools/agent/capture.ts +10 -1
- package/src/mcp/tools/agent/eval.ts +2 -3
- package/src/mcp/tools/agent/how-it-works.ts +1 -1
- package/src/mcp/tools/agent/publish.ts +64 -1
- package/src/mcp/tools/agent/session.ts +29 -8
- package/src/mcp/tools/agent/shared.ts +5 -10
- package/src/mcp/tools/authenticate/authenticate.ts +14 -4
- package/src/mcp/tools/authenticate/builder-access.ts +42 -0
- package/src/mcp/tools/authenticate/index.ts +4 -0
- package/src/mcp/tools/mode/mode.ts +28 -4
- package/src/old/login-cdp.ts +13 -3
- package/src/old/to-register.ts +50 -27
- package/src/old/tools/publish.ts +131 -9
- package/src/provider/draft.ts +98 -56
- package/src/provider/index.ts +9 -1
- package/src/provider/injection-doc.ts +4 -3
- package/src/provider/schema.ts +27 -1
- package/src/provider/secrets.ts +171 -6
- package/src/provider/to-version.ts +53 -7
- package/lib/authoring/find.d.ts +0 -3
- package/lib/authoring/find.js +0 -54
- package/lib/authoring/propose.d.ts +0 -3
- package/lib/authoring/propose.js +0 -109
- package/lib/authoring/replay.d.ts +0 -10
- package/lib/authoring/replay.js +0 -34
- package/lib/cli/args.d.ts +0 -14
- package/lib/cli/args.js +0 -19
- package/lib/cli/commands.d.ts +0 -1
- package/lib/cli/commands.js +0 -19
- package/lib/cli.d.ts +0 -1
- package/lib/cli.js +0 -7
- package/lib/mcp/credentials/authenticate.d.ts +0 -3
- package/lib/mcp/credentials/authenticate.js +0 -108
- package/lib/mcp/credentials/build.d.ts +0 -5
- package/lib/mcp/credentials/build.js +0 -18
- package/lib/mcp/credentials/cache.d.ts +0 -70
- package/lib/mcp/credentials/cache.js +0 -131
- package/lib/mcp/credentials/env-file.d.ts +0 -25
- package/lib/mcp/credentials/env-file.js +0 -71
- package/lib/mcp/credentials/import.d.ts +0 -2
- package/lib/mcp/credentials/import.js +0 -52
- package/lib/mcp/credentials/issue.d.ts +0 -2
- package/lib/mcp/credentials/issue.js +0 -84
- package/lib/mcp/credentials/keypair.d.ts +0 -17
- package/lib/mcp/credentials/keypair.js +0 -48
- package/lib/mcp/credentials/link.d.ts +0 -3
- package/lib/mcp/credentials/link.js +0 -107
- package/lib/mcp/credentials/list-cached.d.ts +0 -2
- package/lib/mcp/credentials/list-cached.js +0 -55
- package/lib/mcp/credentials/owner-key.d.ts +0 -23
- package/lib/mcp/credentials/owner-key.js +0 -58
- package/lib/mcp/credentials/paths.d.ts +0 -12
- package/lib/mcp/credentials/paths.js +0 -35
- package/lib/mcp/credentials/poll.d.ts +0 -27
- package/lib/mcp/credentials/poll.js +0 -61
- package/lib/mcp/credentials/register.d.ts +0 -18
- package/lib/mcp/credentials/register.js +0 -30
- package/lib/mcp/credentials/resolve-owner-key.d.ts +0 -12
- package/lib/mcp/credentials/resolve-owner-key.js +0 -68
- package/lib/mcp/credentials/session-cache.d.ts +0 -14
- package/lib/mcp/credentials/session-cache.js +0 -42
- package/lib/mcp/credentials/status.d.ts +0 -3
- package/lib/mcp/credentials/status.js +0 -76
- package/lib/mcp/register.d.ts +0 -38
- package/lib/mcp/register.js +0 -80
- package/lib/mcp/tools/analyze.d.ts +0 -3
- package/lib/mcp/tools/analyze.js +0 -26
- package/lib/mcp/tools/attach.d.ts +0 -11
- package/lib/mcp/tools/attach.js +0 -72
- package/lib/mcp/tools/capture.d.ts +0 -10
- package/lib/mcp/tools/capture.js +0 -142
- package/lib/mcp/tools/eval.d.ts +0 -5
- package/lib/mcp/tools/eval.js +0 -127
- package/lib/mcp/tools/find.d.ts +0 -3
- package/lib/mcp/tools/find.js +0 -60
- package/lib/mcp/tools/owner-key.d.ts +0 -2
- package/lib/mcp/tools/owner-key.js +0 -63
- package/lib/mcp/tools/proof.d.ts +0 -3
- package/lib/mcp/tools/proof.js +0 -214
- package/lib/mcp/tools/propose.d.ts +0 -9
- package/lib/mcp/tools/propose.js +0 -75
- package/lib/mcp/tools/publish.d.ts +0 -3
- package/lib/mcp/tools/publish.js +0 -59
- package/lib/mcp/tools/replay.d.ts +0 -3
- package/lib/mcp/tools/replay.js +0 -46
- package/lib/mcp/tools/session.d.ts +0 -10
- package/lib/mcp/tools/session.js +0 -29
- package/lib/mcp/tools/verification/diagnose.d.ts +0 -11
- package/lib/mcp/tools/verification/diagnose.js +0 -132
- package/lib/mcp/verifications/build.d.ts +0 -5
- package/lib/mcp/verifications/build.js +0 -14
- package/lib/mcp/verifications/create.d.ts +0 -3
- package/lib/mcp/verifications/create.js +0 -57
- package/lib/mcp/verifications/find.d.ts +0 -3
- package/lib/mcp/verifications/find.js +0 -37
- package/lib/mcp/verifications/get.d.ts +0 -3
- package/lib/mcp/verifications/get.js +0 -26
- package/lib/mcp/verifications/update.d.ts +0 -3
- package/lib/mcp/verifications/update.js +0 -78
- package/lib/old/tools/get-version-config.d.ts +0 -17
- package/lib/old/tools/get-version-config.js +0 -47
- package/lib/routes.gen.d.ts +0 -271
- package/lib/routes.gen.js +0 -73
- package/lib/types/openapi.d.ts +0 -4
- package/lib/types/openapi.gen.d.ts +0 -3967
- package/lib/types/openapi.gen.js +0 -5
- package/lib/types/openapi.js +0 -1
package/src/provider/draft.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
|
|
2
|
+
import { normalizeCandidates } from './normalize.ts'
|
|
2
3
|
import type {
|
|
3
4
|
CapturedRequest,
|
|
4
5
|
OprfMode,
|
|
@@ -7,7 +8,11 @@ import type {
|
|
|
7
8
|
ResponseRedaction,
|
|
8
9
|
SecretParamRef,
|
|
9
10
|
} from './schema.ts'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
secretUrlCharBudget,
|
|
13
|
+
splitSecrets,
|
|
14
|
+
stripTransportHeaders,
|
|
15
|
+
} from './secrets.ts'
|
|
11
16
|
|
|
12
17
|
/** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
|
|
13
18
|
* isn't set up for gnark oprf). */
|
|
@@ -66,51 +71,6 @@ export interface DraftResult {
|
|
|
66
71
|
extractions: Extraction[]
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
/** Request headers that must NOT survive into the provider params. Three kinds:
|
|
70
|
-
* - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
|
|
71
|
-
* cached ETag/date makes the origin answer 304/412 when the attestor
|
|
72
|
-
* re-issues, and the attestor treats non-2xx as failure. We always want the
|
|
73
|
-
* full fresh response.
|
|
74
|
-
* - Transfer headers the ATTESTOR sets itself (`connection: close`,
|
|
75
|
-
* `accept-encoding: identity`). Passing our own duplicates/collides with the
|
|
76
|
-
* request the attestor builds, so leave them out — the attestor already
|
|
77
|
-
* forces `identity` (uncompressed, so its byte-matching works).
|
|
78
|
-
* - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
|
|
79
|
-
* `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
|
|
80
|
-
* which CDP surfaces on h2 captures). The attestor derives `Host` from the
|
|
81
|
-
* URL and sets its own; a baked `Host` produces a DUPLICATE
|
|
82
|
-
* (`found: h,h`) that fails the receipt check / 400s the origin. A stale
|
|
83
|
-
* `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
|
|
84
|
-
* real headers at all. */
|
|
85
|
-
const STRIP_REQUEST_HEADERS = new Set([
|
|
86
|
-
'if-none-match',
|
|
87
|
-
'if-modified-since',
|
|
88
|
-
'if-match',
|
|
89
|
-
'if-unmodified-since',
|
|
90
|
-
'if-range',
|
|
91
|
-
'connection',
|
|
92
|
-
'accept-encoding',
|
|
93
|
-
'host',
|
|
94
|
-
'content-length',
|
|
95
|
-
'transfer-encoding',
|
|
96
|
-
// Per-session anti-CSRF tokens. Not caught by splitSecrets (no `-token`
|
|
97
|
-
// suffix), so without this they baked in as PUBLIC headers and went stale at
|
|
98
|
-
// verify time — the attestor replayed one user's captured CSRF for everyone,
|
|
99
|
-
// which the origin rejects. A CSRF token is never portable; drop it.
|
|
100
|
-
'csrf',
|
|
101
|
-
'x-csrf',
|
|
102
|
-
'xsrf',
|
|
103
|
-
'x-xsrf',
|
|
104
|
-
'x-csrf-token',
|
|
105
|
-
'xsrf-token',
|
|
106
|
-
])
|
|
107
|
-
|
|
108
|
-
/** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
|
|
109
|
-
* `:scheme`) — never a valid request header the attestor can replay. */
|
|
110
|
-
function isPseudoHeader(name: string): boolean {
|
|
111
|
-
return name.startsWith(':')
|
|
112
|
-
}
|
|
113
|
-
|
|
114
74
|
/** A header VALUE that is a per-session/request token — a UUID or a long opaque
|
|
115
75
|
* hex/base64 blob. Such a value is captured-user-specific: baked into the
|
|
116
76
|
* provider it goes stale and the origin rejects the replay for everyone else.
|
|
@@ -124,19 +84,19 @@ function isVolatileHeaderValue(value: string): boolean {
|
|
|
124
84
|
|| /^[A-Za-z0-9_-]{32,}$/.test(v)
|
|
125
85
|
}
|
|
126
86
|
|
|
127
|
-
/** Drop
|
|
128
|
-
*
|
|
129
|
-
*
|
|
87
|
+
/** Drop a PUBLIC header whose value is per-session/request volatile (see
|
|
88
|
+
* {@link isVolatileHeaderValue}) — public headers get frozen into the
|
|
89
|
+
* persisted provider, so a volatile one goes stale and fails every other
|
|
90
|
+
* user's replay. Secret headers are re-captured fresh per verification
|
|
91
|
+
* (never persisted), so this doesn't apply to them; keep every other
|
|
92
|
+
* captured public header so the attestor's replay looks like the browser's
|
|
93
|
+
* request. */
|
|
130
94
|
function sanitizeRequestHeaders(
|
|
131
95
|
headers: Record<string, string>,
|
|
132
96
|
): Record<string, string> {
|
|
133
97
|
const out: Record<string, string> = {}
|
|
134
98
|
for(const [k, v] of Object.entries(headers)) {
|
|
135
|
-
if(
|
|
136
|
-
STRIP_REQUEST_HEADERS.has(k.toLowerCase())
|
|
137
|
-
|| isPseudoHeader(k)
|
|
138
|
-
|| isVolatileHeaderValue(v)
|
|
139
|
-
) {
|
|
99
|
+
if(isVolatileHeaderValue(v)) {
|
|
140
100
|
continue
|
|
141
101
|
}
|
|
142
102
|
|
|
@@ -233,6 +193,79 @@ export function assertPortableMatches(
|
|
|
233
193
|
}
|
|
234
194
|
}
|
|
235
195
|
|
|
196
|
+
/** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
|
|
197
|
+
* request URL. The whole point of `hash` is that the attestor never sees
|
|
198
|
+
* the value in cleartext — but URL `paramValues` are always signed into the
|
|
199
|
+
* attestor's context in the clear (attestor-core: "those in URL... will be
|
|
200
|
+
* put into context and signed"), so a value that's both hashed in the
|
|
201
|
+
* response AND present in the URL is visible there anyway. This isn't a
|
|
202
|
+
* privacy nuance the author can weigh (unlike {@link assertPortableMatches}
|
|
203
|
+
* bare-literal cases) — it doesn't verify. Throwing here, at draft time,
|
|
204
|
+
* surfaces that immediately instead of a confusing later proof failure. */
|
|
205
|
+
export function assertNoOprfUrlConflict(
|
|
206
|
+
url: string,
|
|
207
|
+
targets: DraftTarget[],
|
|
208
|
+
effectiveHashes: (OprfMode | undefined)[],
|
|
209
|
+
) {
|
|
210
|
+
for(const [i, t] of targets.entries()) {
|
|
211
|
+
const hash = effectiveHashes[i]
|
|
212
|
+
if(!hash) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const inUrl = [t.value, ...normalizeCandidates(t.value)]
|
|
217
|
+
.some((v) => url.includes(v))
|
|
218
|
+
if(inUrl) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`target "${t.name}" is OPRF-hashed (${hash}) but its value also `
|
|
221
|
+
+ `appears in the request URL ("${url}") — the attestor signs URL `
|
|
222
|
+
+ 'param values into context in the clear, so it would see this '
|
|
223
|
+
+ 'value regardless of the hash. Drop `hash` for this target, or '
|
|
224
|
+
+ "capture a request whose URL doesn't carry the value.",
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Reject a draft whose URL bakes in a secret header/cookie's value beyond
|
|
231
|
+
* what write-redaction can actually hide there. A secret value used in the
|
|
232
|
+
* URL is NOT an automatic leak — write-redaction (`key-update`/`zk`) can
|
|
233
|
+
* redact it from the attestor's view up to {@link secretUrlCharBudget}
|
|
234
|
+
* characters, summed across every secret value that appears in that URL
|
|
235
|
+
* (not per-value). Below the budget the header buys real privacy; at or
|
|
236
|
+
* over it, the value is effectively visible regardless of hiding the
|
|
237
|
+
* header (same class of problem as {@link assertNoOprfUrlConflict} for
|
|
238
|
+
* OPRF-hashed targets, which has no such budget at all). */
|
|
239
|
+
export function assertNoSecretUrlConflict(
|
|
240
|
+
url: string,
|
|
241
|
+
secrets: Record<string, string>,
|
|
242
|
+
writeRedactionMode?: ReclaimProvider['writeRedactionMode'],
|
|
243
|
+
) {
|
|
244
|
+
let redactedChars = 0
|
|
245
|
+
const overlapping: string[] = []
|
|
246
|
+
for(const [name, value] of Object.entries(secrets)) {
|
|
247
|
+
const inUrl = [value, ...normalizeCandidates(value)]
|
|
248
|
+
.some((v) => url.includes(v))
|
|
249
|
+
if(inUrl) {
|
|
250
|
+
redactedChars += value.length
|
|
251
|
+
overlapping.push(name)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const budget = secretUrlCharBudget(writeRedactionMode)
|
|
256
|
+
if(redactedChars > budget) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`secret header(s) ${overlapping.map((n) => `"${n}"`).join(', ')} `
|
|
259
|
+
+ `also appear in the request URL ("${url}"), totaling `
|
|
260
|
+
+ `${redactedChars} character(s) to hide — over the ${budget}-`
|
|
261
|
+
+ `character budget write-redaction (${writeRedactionMode
|
|
262
|
+
?? 'key-update'}) can actually hide there. Drop the header, `
|
|
263
|
+
+ "shorten/remove the value's overlap with the URL, or switch to "
|
|
264
|
+
+ "'zk' if that raises the budget enough.",
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
236
269
|
/** Derive a `paramValues` key (and capture-group name) from the provider
|
|
237
270
|
* name's last alphanumeric token. `"GitHub Username"` → `"username"`,
|
|
238
271
|
* `"x-followers"` → `"followers"`. */
|
|
@@ -262,6 +295,7 @@ export function draftProvider(
|
|
|
262
295
|
const matches: ResponseMatch[] = []
|
|
263
296
|
const redactions: ResponseRedaction[] = []
|
|
264
297
|
const extractions: Extraction[] = []
|
|
298
|
+
const effectiveHashes: (OprfMode | undefined)[] = []
|
|
265
299
|
let paramValues: Record<string, string> | undefined
|
|
266
300
|
const seenParams = new Set<string>()
|
|
267
301
|
for(const t of targets) {
|
|
@@ -279,6 +313,7 @@ export function draftProvider(
|
|
|
279
313
|
// The model makes the contextual calls (which named fields are sensitive)
|
|
280
314
|
// via an explicit per-target hash; this floor only guarantees the obvious.
|
|
281
315
|
const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value)
|
|
316
|
+
effectiveHashes.push(effectiveHash)
|
|
282
317
|
const synthesis = synthesizeMatcher(
|
|
283
318
|
req.responseBody,
|
|
284
319
|
req.contentType,
|
|
@@ -311,8 +346,15 @@ export function draftProvider(
|
|
|
311
346
|
|
|
312
347
|
// Portability gate: never ship a draft that bakes a per-user value literally.
|
|
313
348
|
assertPortableMatches(matches, redactions, targets)
|
|
314
|
-
|
|
315
|
-
|
|
349
|
+
// OPRF gate: never ship a draft that hashes a value the URL also reveals.
|
|
350
|
+
assertNoOprfUrlConflict(req.url, targets, effectiveHashes)
|
|
351
|
+
|
|
352
|
+
const { secrets, publics } = splitSecrets(
|
|
353
|
+
stripTransportHeaders(req.requestHeaders)
|
|
354
|
+
)
|
|
355
|
+
// Secret-header gate: never ship a draft whose URL reveals the same value a
|
|
356
|
+
// stripped secret header carries — hiding the header buys nothing then.
|
|
357
|
+
assertNoSecretUrlConflict(req.url, secrets)
|
|
316
358
|
// Sanitize the public headers before they enter the provider. Two problems
|
|
317
359
|
// a captured browser request carries: (1) `accept-encoding: gzip, …`, which
|
|
318
360
|
// makes the attestor match against compressed bytes (it does no transparent
|
package/src/provider/index.ts
CHANGED
|
@@ -17,7 +17,15 @@ export type {
|
|
|
17
17
|
ResponseRedaction,
|
|
18
18
|
SecretParamRef,
|
|
19
19
|
} from './schema.ts'
|
|
20
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
assertSecretParamValuesFitUrlBudget,
|
|
22
|
+
classifyHeader,
|
|
23
|
+
classifyParamName,
|
|
24
|
+
secretUrlCharBudget,
|
|
25
|
+
splitSecretParamValues,
|
|
26
|
+
splitSecrets,
|
|
27
|
+
stripTransportHeaders,
|
|
28
|
+
} from './secrets.ts'
|
|
21
29
|
// Recipe → version `requests[]` (a `RequestSelection`) — the same mapping the
|
|
22
30
|
// MCP publish uses, so the cloud authoring publish stays byte-identical.
|
|
23
31
|
export { providerToRequestSelection } from './to-version.ts'
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* It also covers the HAWKEYE-style live-interception path (`injectionType` /
|
|
15
15
|
* `interceptorType`, `disableRequestReplay` / `isDocumentRequestReplayEnabled`)
|
|
16
16
|
* and `bodySniff`, reverse-engineered from the old-devtools Prisma schema,
|
|
17
|
-
* controller code, and dashboard field hints
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* controller code, and dashboard field hints. The replay flag IS settable via
|
|
18
|
+
* the publish tools (`disableRequestReplay` on old-devtools — add-version or
|
|
19
|
+
* the register follow-up; `interceptorOptions` on the builder), so this is
|
|
20
|
+
* recognition + authoring guidance for the remaining internals.
|
|
20
21
|
*/
|
|
21
22
|
import { readFileSync } from 'node:fs'
|
|
22
23
|
import { fileURLToPath } from 'node:url'
|
package/src/provider/schema.ts
CHANGED
|
@@ -15,6 +15,25 @@ export type HttpMethod = HttpParams['method']
|
|
|
15
15
|
*/
|
|
16
16
|
export type ResponseMatch = HttpParams['responseMatches'][number]
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A response match as drafted for PUBLISH: the attestor's match plus
|
|
20
|
+
* `isOptional` — a backend-side matcher flag (both the old devtools backend
|
|
21
|
+
* and the builder store it; the attestor's own params have no such field, so
|
|
22
|
+
* `propose_provider` drafts never set it — only an explicit publish input
|
|
23
|
+
* does). Default false on both backends.
|
|
24
|
+
*/
|
|
25
|
+
export type DraftResponseMatch = ResponseMatch & { isOptional?: boolean }
|
|
26
|
+
|
|
27
|
+
/** How the verification client supplies cookies/auth when replaying a
|
|
28
|
+
* request. Both backends accept the same enum (old devtools on
|
|
29
|
+
* `requestData`, builder on `RequestSelection`); default `include`. */
|
|
30
|
+
export type WebCredentials = 'omit' | 'same-origin' | 'include'
|
|
31
|
+
|
|
32
|
+
/** Old-devtools `urlType` for a requestData entry. The builder backend has
|
|
33
|
+
* no such field (templating is implicit in the URL's `{{param}}`
|
|
34
|
+
* placeholders), so the builder translator ignores it. */
|
|
35
|
+
export type OldUrlType = 'REGEX' | 'CONSTANT' | 'TEMPLATE'
|
|
36
|
+
|
|
18
37
|
/**
|
|
19
38
|
* Selector telling the prover which portion of the response to reveal:
|
|
20
39
|
* `xPath`/`jsonPath` narrows the slice, `regex` (with named groups) matches
|
|
@@ -67,8 +86,15 @@ export interface ReclaimProvider {
|
|
|
67
86
|
method: HttpMethod
|
|
68
87
|
headers?: Record<string, string>
|
|
69
88
|
body?: string
|
|
70
|
-
responseMatches:
|
|
89
|
+
responseMatches: DraftResponseMatch[]
|
|
71
90
|
responseRedactions?: ResponseRedaction[]
|
|
91
|
+
/** How the verification client supplies cookies/auth on replay. Both
|
|
92
|
+
* backends default to `include` when unset. */
|
|
93
|
+
credentials?: WebCredentials
|
|
94
|
+
/** Old-devtools-only url type override (`REGEX`/`CONSTANT`/`TEMPLATE`).
|
|
95
|
+
* Auto-derived from `{{param}}` placeholders when unset; ignored by the
|
|
96
|
+
* builder backend. */
|
|
97
|
+
urlType?: OldUrlType
|
|
72
98
|
/** Public param refs (templated into URL/body), populated by draft layer. */
|
|
73
99
|
paramValues?: Record<string, string>
|
|
74
100
|
/** Redaction mode for the transcript (attestor default: 'key-update'). */
|
package/src/provider/secrets.ts
CHANGED
|
@@ -1,14 +1,99 @@
|
|
|
1
|
-
|
|
1
|
+
import type { ReclaimProvider } from './schema.ts'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Headers safe to reveal to the attestor unredacted — everything else
|
|
4
|
+
* (`Cookie`, `Authorization`, any custom header, even ones that look inert
|
|
5
|
+
* like `Content-Type`) is secret by default. Allowlist, not a denylist:
|
|
6
|
+
* same exact list `reclaim-inapp-sdk` (`_PUBLIC_HEADERS_ALLOW_LIST`,
|
|
7
|
+
* `request.dart`) and `reclaim-portal` (`DEFAULT_PUBLIC_HEADERS`,
|
|
8
|
+
* `claim-builder.ts`) use for their own `witnessParams`/header split. */
|
|
9
|
+
const PUBLIC_HEADERS = new Set([
|
|
10
|
+
'user-agent',
|
|
11
|
+
'accept',
|
|
12
|
+
'accept-language',
|
|
13
|
+
'accept-encoding',
|
|
14
|
+
'sec-fetch-mode',
|
|
15
|
+
'sec-fetch-site',
|
|
16
|
+
'sec-fetch-user',
|
|
17
|
+
'origin',
|
|
18
|
+
'x-requested-with',
|
|
19
|
+
'sec-ch-ua',
|
|
20
|
+
'sec-ch-ua-mobile',
|
|
21
|
+
])
|
|
4
22
|
|
|
5
23
|
export function classifyHeader(name: string): boolean {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
24
|
+
return !PUBLIC_HEADERS.has(name.toLowerCase())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Headers that must NOT survive into EITHER the public or secret side —
|
|
28
|
+
* structural/transport, never a credential. Three kinds:
|
|
29
|
+
* - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
|
|
30
|
+
* cached ETag/date makes the origin answer 304/412 when the attestor
|
|
31
|
+
* re-issues, and the attestor treats non-2xx as failure. We always want
|
|
32
|
+
* the full fresh response.
|
|
33
|
+
* - Transfer headers the ATTESTOR sets itself (`connection: close`,
|
|
34
|
+
* `accept-encoding: identity`). Passing our own duplicates/collides with
|
|
35
|
+
* the request the attestor builds, so leave them out.
|
|
36
|
+
* - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
|
|
37
|
+
* `transfer-encoding`). The attestor derives `Host` from the URL and sets
|
|
38
|
+
* its own; a baked `Host` produces a DUPLICATE that fails the receipt
|
|
39
|
+
* check / 400s the origin. A stale `content-length` breaks a
|
|
40
|
+
* re-serialized body.
|
|
41
|
+
* Must be stripped BEFORE `splitSecrets`, not after: under the allowlist
|
|
42
|
+
* classifier these all fall into `secrets` (none are on `PUBLIC_HEADERS`),
|
|
43
|
+
* and a header like `Host` — whose value trivially mirrors the URL's
|
|
44
|
+
* hostname, by definition — would trip a URL-conflict check on a
|
|
45
|
+
* structural, not a privacy, coincidence. */
|
|
46
|
+
const STRIP_REQUEST_HEADERS = new Set([
|
|
47
|
+
'if-none-match',
|
|
48
|
+
'if-modified-since',
|
|
49
|
+
'if-match',
|
|
50
|
+
'if-unmodified-since',
|
|
51
|
+
'if-range',
|
|
52
|
+
'connection',
|
|
53
|
+
'accept-encoding',
|
|
54
|
+
'host',
|
|
55
|
+
'content-length',
|
|
56
|
+
'transfer-encoding',
|
|
57
|
+
// Per-session anti-CSRF tokens: never portable (baking one in replays a
|
|
58
|
+
// single captured user's token for everyone, which the origin rejects).
|
|
59
|
+
// The allowlist classifier already routes these to `secrets` — a proof
|
|
60
|
+
// attempt sends the CURRENT user's own captured value, which is correct —
|
|
61
|
+
// but stripping them here means they never even take that path, and
|
|
62
|
+
// definitely never survive into a persisted, public recipe.
|
|
63
|
+
'csrf',
|
|
64
|
+
'x-csrf',
|
|
65
|
+
'xsrf',
|
|
66
|
+
'x-xsrf',
|
|
67
|
+
'x-csrf-token',
|
|
68
|
+
'xsrf-token',
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
/** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
|
|
72
|
+
* `:scheme`, which CDP surfaces on h2 captures) — never a valid request
|
|
73
|
+
* header the attestor can replay. Same BEFORE-the-split reasoning as
|
|
74
|
+
* {@link STRIP_REQUEST_HEADERS}. */
|
|
75
|
+
function isPseudoHeader(name: string): boolean {
|
|
76
|
+
return name.startsWith(':')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Drop {@link STRIP_REQUEST_HEADERS} and HTTP/2 pseudo-headers. Call this on
|
|
80
|
+
* the RAW captured headers before {@link splitSecrets} — both the draft-time
|
|
81
|
+
* authoring flow and the live per-user verification capture need it, so
|
|
82
|
+
* neither ends up sending a duplicate `Host` or an invalid `:authority` to
|
|
83
|
+
* the attestor via either the public or the secret channel. */
|
|
84
|
+
export function stripTransportHeaders(
|
|
85
|
+
headers: Record<string, string>,
|
|
86
|
+
): Record<string, string> {
|
|
87
|
+
const out: Record<string, string> = {}
|
|
88
|
+
for(const [k, v] of Object.entries(headers)) {
|
|
89
|
+
if(STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
out[k] = v
|
|
9
94
|
}
|
|
10
95
|
|
|
11
|
-
return
|
|
96
|
+
return out
|
|
12
97
|
}
|
|
13
98
|
|
|
14
99
|
export function splitSecrets(headers: Record<string, string>): {
|
|
@@ -27,3 +112,83 @@ export function splitSecrets(headers: Record<string, string>): {
|
|
|
27
112
|
|
|
28
113
|
return { secrets, publics }
|
|
29
114
|
}
|
|
115
|
+
|
|
116
|
+
/** Total characters write-redaction can hide from the attestor, summed
|
|
117
|
+
* ACROSS EVERY secret-classified value used in the URL — not a per-value
|
|
118
|
+
* cap. Default mode (`'key-update'`, also what an unset field resolves to)
|
|
119
|
+
* caps at 2; `'zk'` at 12. At or under the budget the overlap is genuinely
|
|
120
|
+
* hidden; over it, the value (or the combination of values) is effectively
|
|
121
|
+
* still visible to the attestor regardless of secret-marking. */
|
|
122
|
+
export function secretUrlCharBudget(
|
|
123
|
+
writeRedactionMode: ReclaimProvider['writeRedactionMode'],
|
|
124
|
+
): number {
|
|
125
|
+
return writeRedactionMode === 'zk' ? 12 : 2
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A `paramValues`/context key is secret iff its name contains `SECRET`
|
|
129
|
+
* (case-insensitive) — e.g. `SECRET_apiKey`, `context.SECRET_apiKey`. Same
|
|
130
|
+
* naming-convention classifier reclaim-inapp-sdk and reclaim-portal use for
|
|
131
|
+
* their `witnessParams`/`paramValues` maps (no separate boolean flag). */
|
|
132
|
+
export function classifyParamName(name: string): boolean {
|
|
133
|
+
return name.toUpperCase().includes('SECRET')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Split a `paramValues` map (bare `{{name}}` / `{{context.<name>}}` entries)
|
|
137
|
+
* into public (→ `params.paramValues`, revealed to the attestor) and secret
|
|
138
|
+
* (→ `secretParams.paramValues`) by {@link classifyParamName}. A secret
|
|
139
|
+
* value used in the URL is still hidden — up to {@link secretUrlCharBudget}
|
|
140
|
+
* characters, summed across every such value in that URL. */
|
|
141
|
+
export function splitSecretParamValues(paramValues: Record<string, string>): {
|
|
142
|
+
secrets: Record<string, string>
|
|
143
|
+
publics: Record<string, string>
|
|
144
|
+
} {
|
|
145
|
+
const secrets: Record<string, string> = {}
|
|
146
|
+
const publics: Record<string, string> = {}
|
|
147
|
+
for(const [k, v] of Object.entries(paramValues)) {
|
|
148
|
+
if(classifyParamName(k)) {
|
|
149
|
+
secrets[k] = v
|
|
150
|
+
} else {
|
|
151
|
+
publics[k] = v
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { secrets, publics }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Reject a proof attempt whose URL/geoLocation templates SECRET-named
|
|
159
|
+
* context param(s) whose ACTUAL values (now known — the consumer supplied
|
|
160
|
+
* them at session-create) exceed the write-redaction budget once summed.
|
|
161
|
+
* Structural placement (which params end up in the URL) is knowable at
|
|
162
|
+
* publish time and isn't rejected there — only the real values decide
|
|
163
|
+
* whether it fits, and those only exist once a verification session is
|
|
164
|
+
* underway. Checks the placeholder TOKEN (`{{<key>}}`), not a value search —
|
|
165
|
+
* the URL/geoLocation explicitly reference the param by name. */
|
|
166
|
+
export function assertSecretParamValuesFitUrlBudget(
|
|
167
|
+
url: string,
|
|
168
|
+
geoLocation: string | undefined,
|
|
169
|
+
secretParamValues: Record<string, string>,
|
|
170
|
+
writeRedactionMode: ReclaimProvider['writeRedactionMode'],
|
|
171
|
+
) {
|
|
172
|
+
const haystack = `${url}\n${geoLocation ?? ''}`
|
|
173
|
+
let redactedChars = 0
|
|
174
|
+
const used: string[] = []
|
|
175
|
+
for(const [key, value] of Object.entries(secretParamValues)) {
|
|
176
|
+
if(haystack.includes(`{{${key}}}`)) {
|
|
177
|
+
redactedChars += value.length
|
|
178
|
+
used.push(key)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const budget = secretUrlCharBudget(writeRedactionMode)
|
|
183
|
+
if(redactedChars > budget) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`secret context param(s) ${used.map((k) => `"${k}"`).join(', ')} `
|
|
186
|
+
+ `are templated into the URL/geoLocation ("${url}") with a `
|
|
187
|
+
+ `combined value length of ${redactedChars} character(s) — over `
|
|
188
|
+
+ `the ${budget}-character budget write-redaction `
|
|
189
|
+
+ `(${writeRedactionMode ?? 'key-update'}) can actually hide there. `
|
|
190
|
+
+ "Ask the consumer for a shorter value, switch to 'zk' if that "
|
|
191
|
+
+ 'raises the budget enough, or move this param to the body.',
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -4,10 +4,12 @@ import type {
|
|
|
4
4
|
ProviderVersionRef,
|
|
5
5
|
} from '@reclaimprotocol/client/openapi'
|
|
6
6
|
import type {
|
|
7
|
+
DraftResponseMatch,
|
|
7
8
|
HttpMethod,
|
|
9
|
+
OldUrlType,
|
|
8
10
|
ReclaimProvider,
|
|
9
|
-
ResponseMatch,
|
|
10
11
|
ResponseRedaction,
|
|
12
|
+
WebCredentials,
|
|
11
13
|
} from './schema.ts'
|
|
12
14
|
import { DYNAMIC_GEO, normalizeGeoLocation } from './schema.ts'
|
|
13
15
|
|
|
@@ -24,8 +26,15 @@ export interface DraftRequest {
|
|
|
24
26
|
body?: string
|
|
25
27
|
writeRedactionMode?: ReclaimProvider['writeRedactionMode']
|
|
26
28
|
additionalClientOptions?: ReclaimProvider['additionalClientOptions']
|
|
27
|
-
responseMatches?:
|
|
29
|
+
responseMatches?: DraftResponseMatch[]
|
|
28
30
|
responseRedactions?: ResponseRedaction[]
|
|
31
|
+
/** How the verification client supplies cookies/auth on replay. Default
|
|
32
|
+
* `include` (both backends). */
|
|
33
|
+
credentials?: WebCredentials
|
|
34
|
+
/** Old-devtools `urlType` override. Ignored by THIS translator (the
|
|
35
|
+
* builder backend has no urlType) — read only by the old-devtools
|
|
36
|
+
* translator (`old/to-register.ts`). */
|
|
37
|
+
urlType?: OldUrlType
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/** A request the page's injection script is allowed to fire, plus the
|
|
@@ -60,6 +69,16 @@ export interface CreateVersionOptions {
|
|
|
60
69
|
jsUserScripts?: string
|
|
61
70
|
/** Requests the injection script may fire, as `RequestSelectionTemplate`s. */
|
|
62
71
|
allowedJsRequests?: AllowedJsRequest[]
|
|
72
|
+
/** In-app interception options, mapped verbatim to
|
|
73
|
+
* `webSettings.clientOptions.inapp.interceptorOptions`. `interceptorType`
|
|
74
|
+
* is required by the backend whenever the object is present.
|
|
75
|
+
* `isDocumentRequestReplayEnabled` is the INVERSE of old-devtools'
|
|
76
|
+
* `disableRequestReplay` (default false). */
|
|
77
|
+
interceptorOptions?: {
|
|
78
|
+
interceptorType: 'HAWKEYE' | 'MSWJS' | 'CDP'
|
|
79
|
+
isDocumentRequestReplayEnabled?: boolean
|
|
80
|
+
interceptorSettings?: string
|
|
81
|
+
}
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/**
|
|
@@ -86,6 +105,13 @@ const CONTEXT_PARAM_RE = /\{\{context\.([A-Za-z_][A-Za-z0-9_]*)\}\}/g
|
|
|
86
105
|
* recipe can never reference a context value the Builder doesn't require.
|
|
87
106
|
* Returns undefined when nothing references context (the field is optional).
|
|
88
107
|
* Authors can refine types/descriptions in the version editor.
|
|
108
|
+
*
|
|
109
|
+
* A SECRET-named context param (see `classifyParamName`) is NOT rejected here
|
|
110
|
+
* even when it's templated into the URL/geoLocation — write-redaction can
|
|
111
|
+
* still hide it there, up to `secretUrlCharBudget` characters (summed across
|
|
112
|
+
* every such value in that URL). Whether it actually fits is only knowable
|
|
113
|
+
* once a real value exists, so that check runs at verification time
|
|
114
|
+
* (`verify/run.ts`), against the consumer's actual supplied value — not here.
|
|
89
115
|
*/
|
|
90
116
|
export function deriveRequiredContext(
|
|
91
117
|
providers: ReclaimProvider[],
|
|
@@ -95,7 +121,11 @@ export function deriveRequiredContext(
|
|
|
95
121
|
} = {},
|
|
96
122
|
): CreateProviderVersionRequest['requiredContext'] {
|
|
97
123
|
const parts: string[] = []
|
|
98
|
-
for(const p of
|
|
124
|
+
for(const p of providers) {
|
|
125
|
+
parts.push(p.url, p.body ?? '')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for(const p of injection.allowedJsRequests ?? []) {
|
|
99
129
|
parts.push(p.url, p.body ?? '')
|
|
100
130
|
}
|
|
101
131
|
|
|
@@ -139,14 +169,14 @@ export function providerToRequestSelection(
|
|
|
139
169
|
const responseMatches = (provider.responseMatches ?? []).map((match) => ({
|
|
140
170
|
type: match.type,
|
|
141
171
|
value: match.value,
|
|
142
|
-
isOptional: false,
|
|
172
|
+
isOptional: match.isOptional ?? false,
|
|
143
173
|
}))
|
|
144
174
|
const responseRedactions = provider.responseRedactions ?? []
|
|
145
175
|
|
|
146
176
|
return {
|
|
147
177
|
url: provider.url,
|
|
148
178
|
method: provider.method,
|
|
149
|
-
credentials: 'include',
|
|
179
|
+
credentials: provider.credentials ?? 'include',
|
|
150
180
|
...(provider.body ? { requestBodyTemplate: provider.body } : {}),
|
|
151
181
|
...(provider.writeRedactionMode
|
|
152
182
|
? { writeRedactionMode: provider.writeRedactionMode }
|
|
@@ -177,7 +207,9 @@ export function providerToCreateVersionRequest(
|
|
|
177
207
|
initialUrl: string,
|
|
178
208
|
opts: CreateVersionOptions = {},
|
|
179
209
|
): CreateProviderVersionRequest {
|
|
180
|
-
const {
|
|
210
|
+
const {
|
|
211
|
+
notes, version, jsUserScripts, allowedJsRequests, interceptorOptions,
|
|
212
|
+
} = opts
|
|
181
213
|
const requiredContext = deriveRequiredContext(providers, {
|
|
182
214
|
allowedJsRequests,
|
|
183
215
|
jsUserScripts,
|
|
@@ -206,7 +238,21 @@ export function providerToCreateVersionRequest(
|
|
|
206
238
|
// apply schema defaults to request bodies.
|
|
207
239
|
webSettings: {
|
|
208
240
|
geoLocation: pickGeoLocation(providers),
|
|
209
|
-
clientOptions: {
|
|
241
|
+
clientOptions: {
|
|
242
|
+
portal: { useProxy: true },
|
|
243
|
+
...(interceptorOptions
|
|
244
|
+
? {
|
|
245
|
+
inapp: {
|
|
246
|
+
interceptorOptions: {
|
|
247
|
+
// openapi `default: false` — the generated type
|
|
248
|
+
// requires the key even when the author omits it.
|
|
249
|
+
isDocumentRequestReplayEnabled: false,
|
|
250
|
+
...interceptorOptions,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
: {}),
|
|
255
|
+
},
|
|
210
256
|
// Only set when non-empty; an empty string would store a no-op script.
|
|
211
257
|
...(script ? { jsUserScripts: script } : {}),
|
|
212
258
|
},
|
package/lib/authoring/find.d.ts
DELETED
package/lib/authoring/find.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `find_requests_containing` — search captured responses for any encoding of a
|
|
3
|
-
* value and rank the requests that return it. Authored once as a plain
|
|
4
|
-
* {@link RegisteredTool}; the MCP and cloud loop each pass their own backend
|
|
5
|
-
* resolver (see tools.ts / mcp/tools/agent/shared.ts). Server-side: response
|
|
6
|
-
* bodies stay here, only metadata + a short snippet leave.
|
|
7
|
-
*/
|
|
8
|
-
import { normalizeCandidates } from "../provider/normalize.js";
|
|
9
|
-
import { defineTool } from "../tools/registry.js";
|
|
10
|
-
import { objectSchema, publicRequest } from "./util.js";
|
|
11
|
-
export function findTool(resolveBackend) {
|
|
12
|
-
return defineTool({
|
|
13
|
-
name: 'find_requests_containing',
|
|
14
|
-
description: 'Search captured responses for any encoding of `value` (raw, '
|
|
15
|
-
+ 'URL/JSON/HTML-escaped, …). Returns a ranked candidate list with '
|
|
16
|
-
+ 'the matched variant, score, and a short snippet — used to locate '
|
|
17
|
-
+ 'the request that returns a target. Server-side; bodies stay here.',
|
|
18
|
-
inputSchema: objectSchema({ value: { type: 'string' }, limit: { type: 'number' } }, ['value']),
|
|
19
|
-
}, async (args) => {
|
|
20
|
-
const backend = resolveBackend(args);
|
|
21
|
-
const variants = normalizeCandidates(String(args.value));
|
|
22
|
-
const ranked = [];
|
|
23
|
-
for (const r of backend.capture.requests.values()) {
|
|
24
|
-
if (!r.responseBody) {
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
let hit;
|
|
28
|
-
for (const v of variants) {
|
|
29
|
-
const idx = r.responseBody.indexOf(v);
|
|
30
|
-
if (idx >= 0) {
|
|
31
|
-
const start = Math.max(0, idx - 40);
|
|
32
|
-
const end = Math.min(r.responseBody.length, idx + v.length + 40);
|
|
33
|
-
hit = { snippet: r.responseBody.slice(start, end), variant: v };
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (!hit) {
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
// Prefer JSON responses + smaller bodies (likely API endpoints).
|
|
41
|
-
const score = (r.contentType.includes('json') ? 10 : 0)
|
|
42
|
-
+ Math.max(0, 5 - Math.log10(Math.max(r.size, 1)));
|
|
43
|
-
ranked.push({
|
|
44
|
-
...publicRequest(r),
|
|
45
|
-
matchedVariant: hit.variant,
|
|
46
|
-
snippet: hit.snippet,
|
|
47
|
-
score,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
ranked.sort((a, b) => b.score - a.score);
|
|
51
|
-
const limit = Number(args.limit) || 10;
|
|
52
|
-
return { candidates: ranked.slice(0, limit) };
|
|
53
|
-
});
|
|
54
|
-
}
|