mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { executeRequestTransaction } from './request-transaction'
|
||||
|
||||
const KEY_URL = 'http://127.0.0.1:82/encrypt/server_generate_key.php'
|
||||
const FINAL_URL = 'http://127.0.0.1:82/encrypt/aesserver.php'
|
||||
|
||||
const transaction: BrowserPageCallableTransaction = {
|
||||
version: 2,
|
||||
prerequisites: [{
|
||||
boundary: 'fetch',
|
||||
method: 'GET',
|
||||
url: KEY_URL,
|
||||
requestBodyFormat: 'none',
|
||||
maxRequestBodyBytes: 16 * 1_024,
|
||||
response: {
|
||||
statusCode: 200,
|
||||
url: KEY_URL,
|
||||
bodyFormat: 'json',
|
||||
maxBodyBytes: 64 * 1_024,
|
||||
requiredPaths: ['body.aes_key', 'body.aes_iv'],
|
||||
},
|
||||
}],
|
||||
request: {
|
||||
boundary: 'fetch',
|
||||
method: 'POST',
|
||||
url: FINAL_URL,
|
||||
expectedDestinations: ['body.encryptedData'],
|
||||
bodyFormat: 'json',
|
||||
},
|
||||
inputMode: 'auto',
|
||||
}
|
||||
|
||||
const replacedGlobals = new Map<string, PropertyDescriptor | undefined>()
|
||||
|
||||
function replaceGlobal(name: string, value: unknown): void {
|
||||
if (!replacedGlobals.has(name)) replacedGlobals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true })
|
||||
}
|
||||
|
||||
function response(url: string, body: unknown): Response {
|
||||
const result = new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
Object.defineProperty(result, 'url', { value: url, configurable: true })
|
||||
return result
|
||||
}
|
||||
|
||||
function installPageRuntime(fetch: typeof globalThis.fetch): void {
|
||||
class FakeXMLHttpRequest {
|
||||
open(): void {}
|
||||
setRequestHeader(): void {}
|
||||
send(): void {}
|
||||
}
|
||||
class FakeHTMLFormElement {
|
||||
submit(): void {}
|
||||
requestSubmit(): void {}
|
||||
}
|
||||
const pageWindow = {
|
||||
fetch,
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
alert: () => undefined,
|
||||
confirm: () => false,
|
||||
prompt: () => null,
|
||||
open: () => null,
|
||||
}
|
||||
replaceGlobal('window', pageWindow)
|
||||
replaceGlobal('location', { href: 'http://127.0.0.1:82/' })
|
||||
replaceGlobal('document', {
|
||||
documentElement: null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
})
|
||||
replaceGlobal('navigator', {})
|
||||
replaceGlobal('XMLHttpRequest', FakeXMLHttpRequest)
|
||||
replaceGlobal('HTMLFormElement', FakeHTMLFormElement)
|
||||
replaceGlobal('HTMLButtonElement', class FakeHTMLButtonElement {})
|
||||
replaceGlobal('HTMLInputElement', class FakeHTMLInputElement {})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const [name, descriptor] of replacedGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
||||
else delete (globalThis as Record<string, unknown>)[name]
|
||||
}
|
||||
replacedGlobals.clear()
|
||||
})
|
||||
|
||||
describe('request transaction runtime', () => {
|
||||
it('executes a proven prerequisite and captures the terminal request without sending it', async () => {
|
||||
const network: string[] = []
|
||||
installPageRuntime(async (request) => {
|
||||
const url = request instanceof Request ? request.url : String(request)
|
||||
network.push(url)
|
||||
return response(url, { aes_key: 'dynamic-key', aes_iv: 'dynamic-iv' })
|
||||
})
|
||||
|
||||
const result = await executeRequestTransaction({
|
||||
transaction,
|
||||
logicalInput: { username: 'admin', password: '123456' },
|
||||
timeoutMs: 1_000,
|
||||
invoke: async () => {
|
||||
const keyResponse = await window.fetch(KEY_URL)
|
||||
const key = await keyResponse.json() as { aes_key: string; aes_iv: string }
|
||||
await window.fetch(FINAL_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ encryptedData: `${key.aes_key}:${key.aes_iv}:ciphertext` }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ encryptedData: 'dynamic-key:dynamic-iv:ciphertext' })
|
||||
expect(network).toEqual([KEY_URL])
|
||||
})
|
||||
|
||||
it('fails closed before the network when the page requests an unproven prerequisite', async () => {
|
||||
const network: string[] = []
|
||||
installPageRuntime(async (request) => {
|
||||
network.push(request instanceof Request ? request.url : String(request))
|
||||
return response(KEY_URL, { aes_key: 'dynamic-key', aes_iv: 'dynamic-iv' })
|
||||
})
|
||||
|
||||
await expect(executeRequestTransaction({
|
||||
transaction,
|
||||
logicalInput: {},
|
||||
timeoutMs: 1_000,
|
||||
invoke: async () => {
|
||||
await window.fetch('http://127.0.0.1:82/unrelated')
|
||||
},
|
||||
})).rejects.toThrow('页面尝试访问未授权请求 GET http://127.0.0.1:82/unrelated')
|
||||
expect(network).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a prerequisite response that does not contain the proven dynamic inputs', async () => {
|
||||
installPageRuntime(async () => response(KEY_URL, { value: 'not-a-key-envelope' }))
|
||||
|
||||
await expect(executeRequestTransaction({
|
||||
transaction,
|
||||
logicalInput: {},
|
||||
timeoutMs: 1_000,
|
||||
invoke: async () => {
|
||||
await window.fetch(KEY_URL)
|
||||
},
|
||||
})).rejects.toThrow('缺少目标字段:body.aes_key、body.aes_iv')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserPageCallableTransaction } from '@/types/models'
|
||||
import { requestMatchesTransaction, validateRequestTransactionOutput } from './request-transaction'
|
||||
import {
|
||||
requestMatchesTransaction,
|
||||
resolveRequestTransactionFetchInput,
|
||||
validateRequestTransactionOutput,
|
||||
} from './request-transaction'
|
||||
|
||||
const transaction: BrowserPageCallableTransaction = {
|
||||
version: 2,
|
||||
prerequisites: [],
|
||||
request: {
|
||||
boundary: 'fetch',
|
||||
method: 'POST',
|
||||
url: 'encrypt/aesrsa.php?mode=login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
|
||||
bodyFormat: 'json',
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr', 'beacon', 'form'],
|
||||
}
|
||||
|
||||
describe('request transaction contract', () => {
|
||||
@@ -20,6 +27,10 @@ describe('request transaction contract', () => {
|
||||
'http://127.0.0.1:82/login/encrypt/aesrsa.php?mode=login',
|
||||
'http://127.0.0.1:82/login/index.html',
|
||||
)).toBe(true)
|
||||
expect(resolveRequestTransactionFetchInput(
|
||||
'encrypt/aesrsa.php',
|
||||
'http://127.0.0.1:82/login/index.html',
|
||||
)).toBe('http://127.0.0.1:82/login/encrypt/aesrsa.php')
|
||||
})
|
||||
|
||||
it('rejects another method, origin, path, or query', () => {
|
||||
|
||||
@@ -64,6 +64,14 @@ function comparableUrl(value: string, baseUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRequestTransactionFetchInput(
|
||||
input: RequestInfo | URL,
|
||||
baseUrl = runtimeBaseUrl(),
|
||||
): RequestInfo | URL {
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) return input
|
||||
try { return new URL(String(input), baseUrl).toString() } catch { return input }
|
||||
}
|
||||
|
||||
export function requestMatchesTransaction(
|
||||
transaction: BrowserPageCallableTransaction,
|
||||
method: string,
|
||||
@@ -74,6 +82,16 @@ export function requestMatchesTransaction(
|
||||
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
|
||||
}
|
||||
|
||||
function requestMatchesStep(
|
||||
step: { boundary: CapturedRequest['boundary']; method: string; url: string },
|
||||
request: CapturedRequest,
|
||||
baseUrl = runtimeBaseUrl(),
|
||||
): boolean {
|
||||
return step.boundary === request.boundary
|
||||
&& step.method.toUpperCase() === request.method.toUpperCase()
|
||||
&& comparableUrl(step.url, baseUrl) === comparableUrl(request.url, baseUrl)
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
@@ -111,15 +129,97 @@ function parseForm(value: string): Record<string, string | string[]> {
|
||||
return output
|
||||
}
|
||||
|
||||
function capturedBody(request: CapturedRequest): unknown {
|
||||
function capturedBodyFormat(request: CapturedRequest): BrowserPageCallableTransaction['request']['bodyFormat'] {
|
||||
const contentType = request.headers['content-type']?.toLowerCase() || ''
|
||||
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) {
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return 'form'
|
||||
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) return 'json'
|
||||
return 'raw'
|
||||
}
|
||||
|
||||
function capturedBody(request: CapturedRequest): unknown {
|
||||
const format = capturedBodyFormat(request)
|
||||
if (format === 'json') {
|
||||
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
|
||||
}
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) return parseForm(request.bodyText)
|
||||
if (format === 'form') return parseForm(request.bodyText)
|
||||
return request.bodyText
|
||||
}
|
||||
|
||||
function bodyValue(text: string, format: BrowserPageCallableTransaction['request']['bodyFormat']): unknown {
|
||||
if (format === 'json') {
|
||||
try { return JSON.parse(text) as unknown } catch { throw error('在线前置请求返回的 Body 不是有效 JSON') }
|
||||
}
|
||||
if (format === 'form') return parseForm(text)
|
||||
return text
|
||||
}
|
||||
|
||||
function validatePrerequisiteRequest(
|
||||
step: BrowserPageCallableTransaction['prerequisites'][number],
|
||||
request: CapturedRequest,
|
||||
): void {
|
||||
const length = byteLength(request.bodyText)
|
||||
if (length > step.maxRequestBodyBytes) {
|
||||
throw error(`在线前置请求 Body 超过计划上限 ${step.maxRequestBodyBytes} B`)
|
||||
}
|
||||
if (step.requestBodyFormat === 'none') {
|
||||
if (length !== 0) throw error('在线前置请求意外携带了 Body')
|
||||
return
|
||||
}
|
||||
const actual = capturedBodyFormat(request)
|
||||
if (actual !== step.requestBodyFormat) {
|
||||
throw error(`在线前置请求生成了 ${actual} Body,但录制证据要求 ${step.requestBodyFormat} Body`)
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(response: Response, maximumBytes: number): Promise<string> {
|
||||
const declaredLength = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
|
||||
}
|
||||
const clone = response.clone()
|
||||
if (!clone.body) return ''
|
||||
const reader = clone.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let length = 0
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
if (result.done) break
|
||||
if (!result.value) continue
|
||||
length += result.value.byteLength
|
||||
if (length > maximumBytes) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
|
||||
}
|
||||
chunks.push(result.value)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
const bytes = new Uint8Array(length)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
async function validatePrerequisiteResponse(
|
||||
step: BrowserPageCallableTransaction['prerequisites'][number],
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
if (response.status !== step.response.statusCode) {
|
||||
throw error(`在线前置响应状态为 ${response.status},录制证据要求 ${step.response.statusCode}`)
|
||||
}
|
||||
if (comparableUrl(response.url, runtimeBaseUrl()) !== comparableUrl(step.response.url, runtimeBaseUrl())) {
|
||||
throw error(`在线前置响应到达未计划 URL ${response.url || '(empty)'}`)
|
||||
}
|
||||
const text = await readBoundedResponseText(response, step.response.maxBodyBytes)
|
||||
const value = bodyValue(text, step.response.bodyFormat)
|
||||
validateRequestTransactionOutput(value, step.response.requiredPaths)
|
||||
}
|
||||
|
||||
function readOwnPath(input: unknown, path: string): unknown {
|
||||
let current = input
|
||||
for (const segment of path.split('.').filter(Boolean)) {
|
||||
@@ -300,43 +400,82 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
||||
const restorers: Array<() => void> = []
|
||||
let captured: CapturedRequest | undefined
|
||||
let captureFailure: Error | undefined
|
||||
let prerequisiteIndex = 0
|
||||
let prerequisiteInFlight = false
|
||||
let resolveCapture!: () => void
|
||||
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
||||
const transactionAbort = new AbortController()
|
||||
|
||||
const fail = (reason: unknown): Error => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
const failure = reason instanceof Error && message.startsWith('请求事务失败:') ? reason : error(message)
|
||||
captureFailure = failure
|
||||
if (!transactionAbort.signal.aborted) transactionAbort.abort(failure)
|
||||
resolveCapture()
|
||||
return failure
|
||||
}
|
||||
|
||||
const capture = async (request: CapturedRequest): Promise<void> => {
|
||||
if (captured || captureFailure) {
|
||||
captureFailure = error('页面流程产生了多个网络请求,无法唯一确定转换边界')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
throw fail('页面流程产生了目标请求之外的额外网络请求')
|
||||
}
|
||||
if (!requestMatchesTransaction(input.transaction, request.method, request.url)) {
|
||||
captureFailure = error(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
if (prerequisiteInFlight || prerequisiteIndex !== input.transaction.prerequisites.length) {
|
||||
throw fail('页面在在线前置请求完成前尝试生成最终业务请求')
|
||||
}
|
||||
if (!requestMatchesStep(input.transaction.request, request)) {
|
||||
throw fail(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
||||
}
|
||||
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
||||
captureFailure = error('页面生成的请求 Body 超过 8 MiB')
|
||||
resolveCapture()
|
||||
throw captureFailure
|
||||
throw fail('页面生成的请求 Body 超过 8 MiB')
|
||||
}
|
||||
captured = request
|
||||
resolveCapture()
|
||||
}
|
||||
|
||||
const previousFetch = window.fetch
|
||||
setMethod(window, 'fetch', (async function transactionFetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = new Request(requestInput, init)
|
||||
await capture({
|
||||
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
|
||||
const observed: CapturedRequest = {
|
||||
boundary: 'fetch',
|
||||
method: request.method.toUpperCase(),
|
||||
url: request.url,
|
||||
headers: headerRecord(request.headers),
|
||||
bodyText: await request.clone().text(),
|
||||
})
|
||||
return new Response(JSON.stringify({ success: false, error: 'request captured by Yakit Browser Agent' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
|
||||
if (prerequisite) {
|
||||
if (prerequisiteInFlight) throw fail('页面并发发起了多个在线前置请求,无法证明执行顺序')
|
||||
if (!requestMatchesStep(prerequisite, observed)) {
|
||||
throw fail(`页面尝试访问未授权请求 ${observed.method} ${observed.url}`)
|
||||
}
|
||||
let forwardAbort: (() => void) | undefined
|
||||
try {
|
||||
validatePrerequisiteRequest(prerequisite, observed)
|
||||
prerequisiteInFlight = true
|
||||
if (request.signal.aborted) transactionAbort.abort(request.signal.reason)
|
||||
else {
|
||||
forwardAbort = () => transactionAbort.abort(request.signal.reason)
|
||||
request.signal.addEventListener('abort', forwardAbort, { once: true })
|
||||
}
|
||||
const expectedRedirect = comparableUrl(prerequisite.url, runtimeBaseUrl())
|
||||
=== comparableUrl(prerequisite.response.url, runtimeBaseUrl()) ? 'error' : 'follow'
|
||||
const guardedRequest = new Request(request, {
|
||||
redirect: expectedRedirect,
|
||||
signal: transactionAbort.signal,
|
||||
})
|
||||
const response = await Reflect.apply(previousFetch, this, [guardedRequest])
|
||||
await validatePrerequisiteResponse(prerequisite, response)
|
||||
prerequisiteIndex += 1
|
||||
return response
|
||||
} catch (reason) {
|
||||
throw fail(reason)
|
||||
} finally {
|
||||
if (forwardAbort) request.signal.removeEventListener('abort', forwardAbort)
|
||||
prerequisiteInFlight = false
|
||||
}
|
||||
}
|
||||
await capture(observed)
|
||||
return await new Promise<Response>(() => undefined)
|
||||
}) as typeof previousFetch, restorers)
|
||||
|
||||
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
|
||||
@@ -411,7 +550,7 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
||||
await Promise.race([
|
||||
captureSignal,
|
||||
delay(timeoutMs).then(() => {
|
||||
if (!captured && !captureFailure) captureFailure = error('等待页面生成目标请求超时')
|
||||
if (!captured && !captureFailure) fail('等待页面完成在线依赖并生成目标请求超时')
|
||||
}),
|
||||
])
|
||||
if (captureFailure) throw captureFailure
|
||||
@@ -422,10 +561,15 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
||||
await delay(0)
|
||||
if (captureFailure) throw captureFailure
|
||||
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
||||
const actualBodyFormat = capturedBodyFormat(captured)
|
||||
if (actualBodyFormat !== input.transaction.request.bodyFormat) {
|
||||
throw error(`页面生成了 ${actualBodyFormat} Body,但录制证据要求 ${input.transaction.request.bodyFormat} Body`)
|
||||
}
|
||||
const value = capturedBody(captured)
|
||||
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
|
||||
return value
|
||||
} finally {
|
||||
if (!transactionAbort.signal.aborted) transactionAbort.abort(error('请求事务已经结束'))
|
||||
for (const restore of restorers.reverse()) {
|
||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ const callable: Omit<BrowserPageCallable, 'target'> = {
|
||||
paths: ['body.encryptedData', 'body.encryptedKey'],
|
||||
},
|
||||
transaction: {
|
||||
version: 2,
|
||||
prerequisites: [],
|
||||
request: {
|
||||
boundary: 'fetch',
|
||||
method: 'POST', url: 'https://example.test/login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
|
||||
bodyFormat: 'json',
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch', 'xhr'],
|
||||
},
|
||||
provenance: { eventId: 'request-1' },
|
||||
createdAt: 1,
|
||||
@@ -48,4 +51,19 @@ describe('page callable metadata contract', () => {
|
||||
const { execution: _execution, ...legacy } = callable;
|
||||
expect(normalizeCallable(legacy, target)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects the legacy client-authored single-request transaction contract', () => {
|
||||
expect(normalizeCallable({
|
||||
...callable,
|
||||
transaction: {
|
||||
request: {
|
||||
method: 'POST', url: 'https://example.test/login',
|
||||
expectedDestinations: ['body.encryptedData', 'body.encryptedKey'],
|
||||
bodyFormat: 'json',
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries: ['fetch'],
|
||||
},
|
||||
}, target)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import { ExtensionError } from '@/shared/errors';
|
||||
import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targets';
|
||||
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
|
||||
import { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client';
|
||||
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
||||
import {
|
||||
MAX_CALLABLE_TIMEOUT_MS,
|
||||
@@ -26,23 +27,54 @@ function normalizeTransaction(value: unknown): BrowserPageCallable['transaction'
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<NonNullable<BrowserPageCallable['transaction']>>;
|
||||
const request = input.request as Partial<NonNullable<BrowserPageCallable['transaction']>['request']> | undefined;
|
||||
if (!request || typeof request.method !== 'string' || typeof request.url !== 'string'
|
||||
if (input.version !== 2 || !Array.isArray(input.prerequisites) || input.prerequisites.length > 4
|
||||
|| !request || !['fetch', 'xhr', 'beacon', 'form'].includes(String(request.boundary))
|
||||
|| typeof request.method !== 'string' || typeof request.url !== 'string'
|
||||
|| !Array.isArray(request.expectedDestinations) || request.expectedDestinations.length === 0
|
||||
|| request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim())) return undefined;
|
||||
const boundaries = Array.isArray(input.boundaries)
|
||||
? input.boundaries.filter((item): item is NonNullable<BrowserPageCallable['transaction']>['boundaries'][number] => (
|
||||
['fetch', 'xhr', 'beacon', 'form'].includes(String(item))
|
||||
)).slice(0, 4)
|
||||
: ['fetch', 'xhr', 'beacon', 'form'] as NonNullable<BrowserPageCallable['transaction']>['boundaries'];
|
||||
if (!boundaries.length) return undefined;
|
||||
|| request.expectedDestinations.some((item) => typeof item !== 'string' || !item.trim())
|
||||
|| !['json', 'form', 'raw'].includes(String(request.bodyFormat))) return undefined;
|
||||
const prerequisites = input.prerequisites.flatMap((value) => {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const item = value as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]>;
|
||||
const response = item.response as Partial<NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']> | undefined;
|
||||
if (item.boundary !== 'fetch' || typeof item.method !== 'string' || typeof item.url !== 'string'
|
||||
|| !['none', 'json', 'form', 'raw'].includes(String(item.requestBodyFormat))
|
||||
|| !Number.isSafeInteger(item.maxRequestBodyBytes) || Number(item.maxRequestBodyBytes) < 0
|
||||
|| Number(item.maxRequestBodyBytes) > 8 * 1_024 * 1_024
|
||||
|| !response || !Number.isSafeInteger(response.statusCode) || Number(response.statusCode) < 100
|
||||
|| Number(response.statusCode) > 599 || typeof response.url !== 'string'
|
||||
|| !['json', 'form', 'raw'].includes(String(response.bodyFormat))
|
||||
|| !Number.isSafeInteger(response.maxBodyBytes) || Number(response.maxBodyBytes) < 1
|
||||
|| Number(response.maxBodyBytes) > 8 * 1_024 * 1_024
|
||||
|| !Array.isArray(response.requiredPaths) || response.requiredPaths.length === 0
|
||||
|| response.requiredPaths.some((path) => typeof path !== 'string' || !path.trim())) return [];
|
||||
return [{
|
||||
boundary: 'fetch' as const,
|
||||
method: item.method.toUpperCase().slice(0, 16),
|
||||
url: item.url.slice(0, 4_096),
|
||||
requestBodyFormat: item.requestBodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['requestBodyFormat'],
|
||||
maxRequestBodyBytes: Number(item.maxRequestBodyBytes),
|
||||
response: {
|
||||
statusCode: Number(response.statusCode),
|
||||
url: response.url.slice(0, 4_096),
|
||||
bodyFormat: response.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['prerequisites'][number]['response']['bodyFormat'],
|
||||
maxBodyBytes: Number(response.maxBodyBytes),
|
||||
requiredPaths: [...new Set(response.requiredPaths.map((path) => path.trim().slice(0, 512)))].slice(0, 64),
|
||||
},
|
||||
}];
|
||||
});
|
||||
if (prerequisites.length !== input.prerequisites.length) return undefined;
|
||||
return {
|
||||
version: 2,
|
||||
prerequisites,
|
||||
request: {
|
||||
boundary: request.boundary as NonNullable<BrowserPageCallable['transaction']>['request']['boundary'],
|
||||
method: request.method.toUpperCase().slice(0, 16),
|
||||
url: request.url.slice(0, 4_096),
|
||||
expectedDestinations: request.expectedDestinations.slice(0, 64).map((item) => item.slice(0, 512)),
|
||||
expectedDestinations: [...new Set(request.expectedDestinations.map((item) => item.trim().slice(0, 512)))].slice(0, 64),
|
||||
bodyFormat: request.bodyFormat as NonNullable<BrowserPageCallable['transaction']>['request']['bodyFormat'],
|
||||
},
|
||||
inputMode: 'auto',
|
||||
boundaries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +88,51 @@ function normalizeExecution(value: unknown): BrowserPageCallable['execution'] |
|
||||
return callableExecutionPolicy(input.resultMode as BrowserPageCallable['execution']['resultMode'], Number(input.timeoutMs));
|
||||
}
|
||||
|
||||
function normalizeCallableAnalysis(
|
||||
value: unknown,
|
||||
): BrowserPageCallable['provenance']['analysis'] | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<NonNullable<BrowserPageCallable['provenance']['analysis']>>;
|
||||
if (input.version !== 1 || typeof input.traceId !== 'string'
|
||||
|| !input.confidence || typeof input.confidence !== 'object'
|
||||
|| !Number.isFinite(input.confidence.score)
|
||||
|| !['high', 'medium', 'low'].includes(String(input.confidence.level))
|
||||
|| !Array.isArray(input.flow) || !Array.isArray(input.operations) || !Array.isArray(input.evidence)) return undefined;
|
||||
const evidenceKinds = new Set([
|
||||
'request-boundary', 'response-boundary', 'exact-value', 'message-boundary',
|
||||
'state-sequence', 'transform-lineage', 'callable', 'trace-order', 'heuristic',
|
||||
]);
|
||||
return {
|
||||
version: 1,
|
||||
traceId: input.traceId.slice(0, 160),
|
||||
confidence: {
|
||||
score: Math.max(0, Math.min(100, Number(input.confidence.score))),
|
||||
level: input.confidence.level as 'high' | 'medium' | 'low',
|
||||
},
|
||||
flow: input.flow.slice(0, 32).flatMap((item) => typeof item === 'string' ? [item.slice(0, 240)] : []),
|
||||
operations: input.operations.slice(0, 16).flatMap((operation) => (
|
||||
operation && typeof operation.operation === 'string'
|
||||
? [{
|
||||
operation: operation.operation.slice(0, 240),
|
||||
destination: typeof operation.destination === 'string' ? operation.destination.slice(0, 512) : undefined,
|
||||
crypto: normalizeBrowserRecordingCrypto(operation.crypto),
|
||||
}]
|
||||
: []
|
||||
)),
|
||||
evidence: input.evidence.slice(0, 24).flatMap((item) => (
|
||||
item && evidenceKinds.has(String(item.kind))
|
||||
&& ['proven', 'supported'].includes(String(item.strength))
|
||||
&& typeof item.label === 'string'
|
||||
? [{
|
||||
kind: item.kind,
|
||||
strength: item.strength,
|
||||
label: item.label.slice(0, 500),
|
||||
}]
|
||||
: []
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCallable | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const input = value as Partial<RawCallable>;
|
||||
@@ -118,6 +195,22 @@ function normalizeCallable(value: unknown, target: BrowserTarget): BrowserPageCa
|
||||
sourceUrl: typeof input.provenance.sourceUrl === 'string' ? input.provenance.sourceUrl.slice(0, 4_096) : undefined,
|
||||
lineNumber: Number.isSafeInteger(input.provenance.lineNumber) ? Math.max(1, Number(input.provenance.lineNumber)) : undefined,
|
||||
functionName: typeof input.provenance.functionName === 'string' ? input.provenance.functionName.slice(0, 240) : undefined,
|
||||
businessFrameHints: Array.isArray(input.provenance.businessFrameHints)
|
||||
? input.provenance.businessFrameHints.slice(0, 16).flatMap((hint) => (
|
||||
hint
|
||||
&& typeof hint.functionName === 'string'
|
||||
&& Number.isFinite(hint.support)
|
||||
&& Number.isFinite(hint.averageDepth)
|
||||
? [{
|
||||
functionName: hint.functionName.slice(0, 240),
|
||||
url: typeof hint.url === 'string' ? hint.url.slice(0, 4_096) : undefined,
|
||||
support: Math.max(0, Number(hint.support)),
|
||||
averageDepth: Math.max(0, Number(hint.averageDepth)),
|
||||
}]
|
||||
: []
|
||||
))
|
||||
: undefined,
|
||||
analysis: normalizeCallableAnalysis(input.provenance.analysis),
|
||||
},
|
||||
createdAt: Number.isFinite(input.createdAt) ? Math.max(0, Number(input.createdAt)) : Date.now(),
|
||||
};
|
||||
@@ -156,6 +249,7 @@ async function callPageController(
|
||||
command: PageControllerCommand,
|
||||
input: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
|
||||
const [result] = await browser.scripting.executeScript({
|
||||
target: scriptingTarget(target),
|
||||
world: 'MAIN',
|
||||
|
||||
Reference in New Issue
Block a user