mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 03:10:43 +08:00
fix(capture): automate safe bidirectional gateways for users and agents
This commit is contained in:
@@ -27,6 +27,8 @@
|
|||||||
"verify:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
|
"verify:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
|
||||||
"verify:aesserver": "node scripts/verify-aesserver-transaction.mjs",
|
"verify:aesserver": "node scripts/verify-aesserver-transaction.mjs",
|
||||||
"verify:des": "node scripts/verify-des-transaction.mjs",
|
"verify:des": "node scripts/verify-des-transaction.mjs",
|
||||||
|
"verify:capture": "pnpm build:enterprise && node scripts/verify-capture-regressions.mjs && node scripts/verify-login-gateway.mjs",
|
||||||
|
"verify:agent-gateway": "pnpm build:enterprise && node scripts/verify-agent-gateway.mjs",
|
||||||
"verify:agent-contract:aes": "node scripts/verify-aes-agent-contract.mjs",
|
"verify:agent-contract:aes": "node scripts/verify-aes-agent-contract.mjs",
|
||||||
"verify:agent-contract:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
|
"verify:agent-contract:aesrsa": "node scripts/verify-aesrsa-transaction.mjs",
|
||||||
"verify:agent-contract:holdout": "AGENT_CONTRACT_HOLDOUT_ONLY=1 EXTENSION_PATH=.output/chrome-mv3-enterprise node scripts/verify-ui.mjs",
|
"verify:agent-contract:holdout": "AGENT_CONTRACT_HOLDOUT_ONLY=1 EXTENSION_PATH=.output/chrome-mv3-enterprise node scripts/verify-ui.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { createDecipheriv } from 'node:crypto'
|
||||||
|
import { extensionRequest, launchBrowserAgentContractHarness } from './browser-agent-contract-harness.mjs'
|
||||||
|
|
||||||
|
function body(packet) { return JSON.parse(packet.raw.slice(packet.raw.indexOf('\r\n\r\n') + 4)) }
|
||||||
|
function decrypt(envelope) {
|
||||||
|
const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
|
||||||
|
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
|
||||||
|
const engine = spawn('go', ['test', './common/yakgrpc', '-run', '^TestBrowserAgentLiveGateway$', '-count=1', '-v'], {
|
||||||
|
cwd: process.env.YAKLANG_ROOT || resolve(import.meta.dirname, '../../../go/yaklang'),
|
||||||
|
env: { ...process.env, YAK_BROWSER_AGENT_E2E: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
})
|
||||||
|
let output = ''
|
||||||
|
let harness, connection
|
||||||
|
const exited = new Promise(resolve => engine.once('exit', code => resolve(code)))
|
||||||
|
const ready = new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Go Agent bridge startup timed out: ${output.slice(-4000)}`)), 120_000)
|
||||||
|
engine.stdout.on('data', chunk => {
|
||||||
|
output += chunk
|
||||||
|
const match = output.match(/YAK_AGENT_E2E=(\{[^\n]+\})/)
|
||||||
|
if (match) { clearTimeout(timer); resolve(JSON.parse(match[1])) }
|
||||||
|
})
|
||||||
|
engine.stderr.on('data', chunk => { output += chunk })
|
||||||
|
engine.once('error', error => { clearTimeout(timer); reject(error) })
|
||||||
|
engine.once('exit', code => { clearTimeout(timer); reject(new Error(`Go bridge exited ${code}: ${output}`)) })
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
connection = await ready
|
||||||
|
const tool = async (name, params = {}) => {
|
||||||
|
const response = await fetch(connection.endpoint, { method: 'POST', headers: { 'X-Test-Token': connection.token }, body: JSON.stringify({ tool: name, params }) })
|
||||||
|
if (!response.ok) throw new Error(`${name}: ${await response.text()}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-agent-gateway-', targetURL })
|
||||||
|
const { controlPage, targetPage, tabId } = harness
|
||||||
|
let completed = 0, blocked = 0
|
||||||
|
targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completed++ })
|
||||||
|
targetPage.on('requestfailed', request => { if (request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blocked++ })
|
||||||
|
const state = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'state.get' })).data)
|
||||||
|
await extensionRequest(controlPage, 'bridge.config.save', {
|
||||||
|
transport: 'websocket', nativeHost: 'com.yaklang.browser_agent', endpoint: connection.bridge,
|
||||||
|
autoConnect: false, installationId: state.bridge.installationId,
|
||||||
|
})
|
||||||
|
await controlPage.evaluate(async () => {
|
||||||
|
const result = await chrome.runtime.sendMessage({ action: 'bridge.pair' })
|
||||||
|
if (!result.ok) throw new Error(JSON.stringify(result.error))
|
||||||
|
})
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const status = await controlPage.evaluate(async () => (await chrome.runtime.sendMessage({ action: 'bridge.status' })).data)
|
||||||
|
if (status.state === 'connected') break
|
||||||
|
if (attempt === 100) throw new Error(`Bridge did not connect: ${JSON.stringify(status)}`)
|
||||||
|
await controlPage.waitForTimeout(100)
|
||||||
|
}
|
||||||
|
// Close the extension UI. All workflow calls below go through Go Agent tools.
|
||||||
|
// A previously released human debugging session must not lock out the Agent.
|
||||||
|
await extensionRequest(controlPage, 'deep.capture.start', { tabId, frameId: 0, matcher: { kind: 'request', urlPattern: '/not-triggered' } })
|
||||||
|
await extensionRequest(controlPage, 'deep.capture.detach', { tabId, frameId: 0 })
|
||||||
|
await controlPage.close()
|
||||||
|
const call = (method, params = {}) => tool('browser.capability.call', { method, params: { tabId, frameId: 0, ...params } })
|
||||||
|
const catalog = await tool('browser.capability.catalog', { domain: 'debugger' })
|
||||||
|
assert(catalog.capabilities.some(item => item.method === 'browser.deep_capture.start'))
|
||||||
|
const context = await call('browser.context', { includeDom: true })
|
||||||
|
const nodes = context.document.interactive
|
||||||
|
const username = nodes.find(node => node.name === 'username' || node.id === 'username')
|
||||||
|
const password = nodes.find(node => node.name === 'password' || node.id === 'password')
|
||||||
|
const submit = nodes.find(node => node.tag === 'button' && node.type === 'submit')
|
||||||
|
assert(username && password && submit, JSON.stringify(nodes))
|
||||||
|
for (const [node, value] of [[username, 'agent-original'], [password, 'agent-original-wrong']]) {
|
||||||
|
await call('browser.node.action', { captureId: context.captureId, nodeId: node.nodeId, action: 'setValue', value })
|
||||||
|
}
|
||||||
|
const inspection = await tool('browser.crypto.inspect', { tabId, frameId: 0, captureId: context.captureId, nodeId: submit.nodeId })
|
||||||
|
assert.equal(inspection.gatewayPreparation.state, 'capture-required')
|
||||||
|
const plaintext = { username: 'agent-new-user', password: 'agent-new-wrong' }
|
||||||
|
const url = new URL(targetURL)
|
||||||
|
const request = `POST ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(plaintext)}`
|
||||||
|
const prepareInput = { ...inspection.target, candidate_id: inspection.gatewayPreparation.candidateId, request, is_https: false }
|
||||||
|
await assert.rejects(tool('browser.transform.prepare', { ...prepareInput, captureId: 'stale-context', nodeId: submit.nodeId }), /快照已经失效/)
|
||||||
|
assert.equal((await call('browser.deep_capture.status')).state, 'detached', 'failed preparation must release its debugger')
|
||||||
|
assert.equal(completed, 1)
|
||||||
|
const prepared = await tool('browser.transform.prepare', prepareInput)
|
||||||
|
assert(prepared.valid, JSON.stringify(prepared))
|
||||||
|
assert.deepEqual(prepared.validationDraft.directions, { request: true, response: true })
|
||||||
|
assert.equal(completed, 1, 'automatic preparation must not submit another real browser login')
|
||||||
|
assert.equal(blocked, 1)
|
||||||
|
const preparedAgain = await tool('browser.transform.prepare', prepareInput)
|
||||||
|
assert(preparedAgain.valid)
|
||||||
|
assert.equal(blocked, 1, 'repeated preparation must reuse existing captured functions')
|
||||||
|
const tested = await tool('browser.http.test', { validation_id: preparedAgain.validationDraft.id, request, is_https: false })
|
||||||
|
assert(tested.responseTransformEnabled, JSON.stringify(tested))
|
||||||
|
assert(tested.requestTransformed, JSON.stringify(tested))
|
||||||
|
assert(tested.responseTransformed)
|
||||||
|
assert.deepEqual(decrypt(body(tested.wireRequest)), plaintext)
|
||||||
|
assert.deepEqual(body(tested.plaintextResponse), decrypt(body(tested.wireResponse)))
|
||||||
|
const draft = await call('browser.profile.validation.latest')
|
||||||
|
const saved = await tool('browser.capability.call', { method: 'browser.transform.profile.save', params: draft.profile })
|
||||||
|
assert(saved.request.enabled && saved.response.enabled)
|
||||||
|
await tool('browser.capability.call', { method: 'browser.transform.profile.delete', params: { id: saved.id } })
|
||||||
|
assert.equal(completed, 1)
|
||||||
|
assert.equal(await targetPage.locator('#username').inputValue(), 'agent-original')
|
||||||
|
assert.equal(await targetPage.locator('#password').inputValue(), 'agent-original-wrong')
|
||||||
|
console.log('Agent gateway passed: signed Go bridge, advanced capability access, no plugin UI, failed-capture cleanup, automatic business capture, bidirectional validation, independently checked request/response crypto, default response decryption and Profile save/delete.')
|
||||||
|
} finally {
|
||||||
|
await harness?.close()
|
||||||
|
if (connection) await fetch(`${connection.endpoint}/finish`, { method: 'POST', headers: { 'X-Test-Token': connection.token } }).catch(() => undefined)
|
||||||
|
else engine.kill('SIGTERM')
|
||||||
|
const code = await exited
|
||||||
|
if (code !== 0 && connection) throw new Error(`Go integration exited ${code}: ${output.slice(-4000)}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { extensionRequest, launchBrowserAgentContractHarness, waitFor } from './browser-agent-contract-harness.mjs'
|
||||||
|
|
||||||
|
const registryKey = (await readFile(new URL('../src/features/page-callable/constants.ts', import.meta.url), 'utf8')).match(/= '([^']+)'/)[1]
|
||||||
|
const received = []
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
let body = ''
|
||||||
|
for await (const chunk of request) body += chunk
|
||||||
|
received.push({ url: request.url, method: request.method, body })
|
||||||
|
if (request.url === '/key' || request.url === '/bad-key') {
|
||||||
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
response.end(JSON.stringify(request.url === '/key' ? { key: 'fresh-server-key' } : {}))
|
||||||
|
} else if (request.method === 'POST') {
|
||||||
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
response.end('{}')
|
||||||
|
} else {
|
||||||
|
response.setHeader('Content-Type', 'text/html')
|
||||||
|
response.end('<form id="form"><input name="password" value="old"><button>Submit</button></form><script>globalThis.cachedFetch = fetch.bind(window); globalThis.cachedSubmit = HTMLFormElement.prototype.submit;</script>')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const targetURL = `http://127.0.0.1:${server.address().port}/`
|
||||||
|
let harness
|
||||||
|
try {
|
||||||
|
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-capture-regressions-', targetURL })
|
||||||
|
const { controlPage, targetPage, tabId } = harness
|
||||||
|
const target = { tabId, frameId: 0 }
|
||||||
|
const request = (action, payload = {}) => extensionRequest(controlPage, action, { ...target, ...payload })
|
||||||
|
await request('recording.start', { captureValues: true, maxEntries: 200, maxValueBytes: 8192 })
|
||||||
|
|
||||||
|
await targetPage.evaluate(async () => {
|
||||||
|
await fetch(new Request(location.origin + '/request-object', { method: 'POST', body: 'encrypted-request-body' }))
|
||||||
|
})
|
||||||
|
const recorded = await waitFor(controlPage, 'recording.get', target, snapshot => snapshot.events.some(event =>
|
||||||
|
event.url?.endsWith('/request-object') && event.inputs.some(value => value.path === '$body' && value.preview === 'encrypted-request-body')))
|
||||||
|
assert(recorded.events.some(event => event.url?.endsWith('/request-object')))
|
||||||
|
|
||||||
|
async function register(mode, transaction = false) {
|
||||||
|
return targetPage.evaluate(({ key, mode, transaction }) => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const endpoint = location.origin + '/' + mode
|
||||||
|
const savedState = { password: 'old' }
|
||||||
|
const prerequisites = mode.startsWith('prerequisite') ? [{
|
||||||
|
boundary: 'fetch', method: 'GET', url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'),
|
||||||
|
requestBodyFormat: 'none', maxRequestBodyBytes: 0,
|
||||||
|
response: { statusCode: 200, url: location.origin + (mode === 'prerequisite-invalid' ? '/bad-key' : '/key'), bodyFormat: 'json', maxBodyBytes: 4096, requiredPaths: ['body.key'] },
|
||||||
|
}] : []
|
||||||
|
if (mode === 'controlled') document.querySelector('input').addEventListener('input', event => { savedState.password = event.target.value })
|
||||||
|
const metadata = {
|
||||||
|
id, name: mode, kind: transaction ? 'request-transaction' : 'business-closure', operation: mode,
|
||||||
|
origin: location.origin, lifecycle: 'document', execution: { resultMode: 'auto', timeoutMs: 1500 },
|
||||||
|
inputSlots: [{ id: 'body', name: 'body', index: 0, role: 'data', dataType: 'object', required: true, retained: false }],
|
||||||
|
output: { dataType: transaction ? 'object' : 'string', encoding: transaction ? 'json' : 'utf8', shape: transaction ? 'envelope' : 'value', paths: transaction ? ['body.password'] : [] },
|
||||||
|
provenance: {}, createdAt: Date.now(),
|
||||||
|
...(transaction ? { transaction: { version: 2, prerequisites, inputMode: 'auto', request: {
|
||||||
|
boundary: mode === 'multipart-xhr' ? 'xhr' : mode === 'multipart-beacon' ? 'beacon' : 'fetch',
|
||||||
|
method: 'POST', url: endpoint, bodyFormat: mode.startsWith('multipart') ? 'form' : 'json', expectedDestinations: ['body.password'],
|
||||||
|
} } } : {}),
|
||||||
|
}
|
||||||
|
const registry = globalThis[key] ||= new Map()
|
||||||
|
registry.set(id, { metadata, invoke(args) {
|
||||||
|
if (mode === 'native-form') {
|
||||||
|
const form = document.querySelector('form'); form.method = 'POST'; form.action = endpoint
|
||||||
|
Reflect.apply(globalThis.cachedSubmit, form, []); return 'unexpected'
|
||||||
|
}
|
||||||
|
if (mode === 'cached') return globalThis.cachedFetch(endpoint, { method: 'POST', body: 'leak' }).then(() => 'bad')
|
||||||
|
if (mode === 'timer') { setTimeout(() => fetch(endpoint, { method: 'POST', body: 'leak' }), 100); return 'ok' }
|
||||||
|
const value = mode === 'stale' || mode === 'controlled' ? savedState : args[0]
|
||||||
|
const send = () => {
|
||||||
|
if (mode.startsWith('multipart')) {
|
||||||
|
const form = new FormData(); form.set('password', value.password)
|
||||||
|
if (mode === 'multipart-xhr') { const xhr = new XMLHttpRequest(); xhr.open('POST', endpoint); return xhr.send(form) }
|
||||||
|
if (mode === 'multipart-beacon') return navigator.sendBeacon(endpoint, form)
|
||||||
|
return fetch(endpoint, { method: 'POST', body: form })
|
||||||
|
}
|
||||||
|
return fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
|
||||||
|
}
|
||||||
|
if (mode.startsWith('concurrent')) return new Promise(resolve => setTimeout(resolve, 40)).then(send)
|
||||||
|
if (prerequisites.length) return fetch(mode === 'prerequisite-unplanned' ? '/unplanned' : prerequisites[0].url).then(response => response.json()).then(send)
|
||||||
|
return send()
|
||||||
|
} })
|
||||||
|
return id
|
||||||
|
}, { key: registryKey, mode, transaction })
|
||||||
|
}
|
||||||
|
const execute = (id, password = 'new') => request('callable.execute', { callableId: id, args: [{ password }] })
|
||||||
|
await assert.rejects(execute(await register('cached')), /fetch|网络|阻止|Failed/i)
|
||||||
|
assert.equal((await execute(await register('timer'))).value, 'ok')
|
||||||
|
await targetPage.waitForTimeout(160)
|
||||||
|
assert(!received.some(item => ['/cached', '/timer'].includes(item.url)))
|
||||||
|
await assert.rejects(execute(await register('stale', true)), /新明文|旧状态/)
|
||||||
|
for (const mode of ['controlled', 'multipart', 'multipart-xhr', 'multipart-beacon']) {
|
||||||
|
assert.deepEqual((await execute(await register(mode, true))).value, { password: 'new' })
|
||||||
|
assert.equal(await targetPage.locator('input').inputValue(), 'old')
|
||||||
|
}
|
||||||
|
const concurrent = await Promise.all([register('concurrent-a', true), register('concurrent-b', true)])
|
||||||
|
const results = await Promise.all(concurrent.map((id, index) => execute(id, `new-${index}`)))
|
||||||
|
assert.deepEqual(results.map(result => result.value), [{ password: 'new-0' }, { password: 'new-1' }])
|
||||||
|
assert(!received.some(item => ['/stale', '/controlled', '/multipart', '/multipart-xhr', '/multipart-beacon', '/concurrent-a', '/concurrent-b'].includes(item.url)))
|
||||||
|
assert.deepEqual((await execute(await register('prerequisite', true))).value, { password: 'new' })
|
||||||
|
assert.equal(received.filter(item => item.url === '/key').length, 1)
|
||||||
|
await assert.rejects(execute(await register('prerequisite-invalid', true)), /缺少目标字段/)
|
||||||
|
await assert.rejects(execute(await register('prerequisite-unplanned', true)), /未授权请求/)
|
||||||
|
assert(!received.some(item => item.url === '/unplanned' || item.method === 'POST' && item.url.startsWith('/prerequisite')))
|
||||||
|
assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
|
||||||
|
const nonWritableId = await register('timer')
|
||||||
|
await targetPage.evaluate(() => {
|
||||||
|
globalThis.beforeIsolation = { fetch, setTimeout, sendBeacon: navigator.sendBeacon }
|
||||||
|
Object.defineProperty(navigator, 'sendBeacon', { value: navigator.sendBeacon, writable: false, configurable: true })
|
||||||
|
})
|
||||||
|
await assert.rejects(execute(nonWritableId), /不能隔离页面边界/)
|
||||||
|
assert(await targetPage.evaluate(() => {
|
||||||
|
const restored = fetch === globalThis.beforeIsolation.fetch && setTimeout === globalThis.beforeIsolation.setTimeout
|
||||||
|
Object.defineProperty(navigator, 'sendBeacon', { value: globalThis.beforeIsolation.sendBeacon, writable: true, configurable: true })
|
||||||
|
return restored
|
||||||
|
}))
|
||||||
|
assert.equal((await controlPage.evaluate(() => chrome.declarativeNetRequest.getSessionRules())).length, 0)
|
||||||
|
|
||||||
|
// A strict arrow listener in a one-line script must resolve by its exact function location.
|
||||||
|
await targetPage.addScriptTag({ content: `document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault(); fetch('/strict', { method: 'POST', body: 'cipher' }) }); document.querySelector('form').addEventListener('submit', e => { 'use strict'; e.preventDefault() });` })
|
||||||
|
await request('deep.capture.start', { matcher: { kind: 'request', urlPattern: '/strict' } })
|
||||||
|
const submit = targetPage.locator('form button').click({ noWaitAfter: true, timeout: 20_000 })
|
||||||
|
const paused = await waitFor(controlPage, 'deep.capture.status', target, value => value.state === 'paused' && !value.pause.collecting)
|
||||||
|
const resolved = paused.pause.frames.filter(frame => frame.functionInspection?.resolution === 'event-listener')
|
||||||
|
assert.equal(resolved.length, 1, JSON.stringify(paused.pause))
|
||||||
|
assert(resolved[0].functionInspection.resolved)
|
||||||
|
await request('deep.capture.resume')
|
||||||
|
await submit
|
||||||
|
const nativeFormId = await register('native-form')
|
||||||
|
const blockedForm = targetPage.waitForEvent('requestfailed', { predicate: request => request.url().endsWith('/native-form'), timeout: 10_000 })
|
||||||
|
void blockedForm.catch(() => undefined)
|
||||||
|
let nativeFormError
|
||||||
|
await assert.rejects(execute(nativeFormId), error => { nativeFormError = error.message; return true })
|
||||||
|
const failedForm = await blockedForm.catch(error => { throw new Error(`${error.message}; execution=${nativeFormError}; url=${targetPage.url()}; received=${JSON.stringify(received)}`) })
|
||||||
|
assert(failedForm.failure().errorText.includes('BLOCKED_BY_CLIENT'))
|
||||||
|
assert(!received.some(item => item.url === '/native-form'))
|
||||||
|
console.log('Capture regressions passed: Request body, native network guard, timer cleanup, fresh input, controlled forms, multipart, concurrent callables, strict same-line listeners.')
|
||||||
|
} finally {
|
||||||
|
await harness?.close()
|
||||||
|
await new Promise(resolve => server.close(resolve))
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createDecipheriv } from 'node:crypto'
|
||||||
|
import { extensionRequest, launchBrowserAgentContractHarness, transformedFetchOptions, waitFor } from './browser-agent-contract-harness.mjs'
|
||||||
|
|
||||||
|
const targetURL = process.env.LOGIN_TARGET || 'http://localhost:8080/crypto/sqli/aes-ecb/encrypt/login'
|
||||||
|
function decrypt(envelope) {
|
||||||
|
const decipher = createDecipheriv('aes-128-cbc', Buffer.from(envelope.key, 'hex'), Buffer.from(envelope.iv, 'hex'))
|
||||||
|
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.message, 'base64')), decipher.final()]).toString())
|
||||||
|
}
|
||||||
|
let harness
|
||||||
|
try {
|
||||||
|
harness = await launchBrowserAgentContractHarness({ profilePrefix: 'yakit-login-gateway-', targetURL })
|
||||||
|
const { targetPage, controlPage, tabId, extensionId } = harness
|
||||||
|
const target = { tabId, frameId: 0 }
|
||||||
|
let completedPosts = 0
|
||||||
|
let blockedPosts = 0
|
||||||
|
targetPage.on('response', response => { if (response.url() === targetURL && response.request().method() === 'POST') completedPosts++ })
|
||||||
|
targetPage.on('requestfailed', request => {
|
||||||
|
if (request.url() === targetURL && request.method() === 'POST' && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT')) blockedPosts++
|
||||||
|
})
|
||||||
|
await extensionRequest(controlPage, 'recording.start', { ...target, captureValues: true, maxEntries: 300, maxValueBytes: 8192 })
|
||||||
|
await targetPage.locator('#username').fill('audit-original')
|
||||||
|
await targetPage.locator('#password').fill('original-wrong-password')
|
||||||
|
await targetPage.locator('button[type=submit]').click()
|
||||||
|
const recorded = await waitFor(controlPage, 'recording.get', target, snapshot =>
|
||||||
|
snapshot.profileCandidates.some(candidate => candidate.direction === 'response' && candidate.status === 'ready'))
|
||||||
|
await extensionRequest(controlPage, 'recording.stop', target)
|
||||||
|
assert.equal(completedPosts, 1)
|
||||||
|
const responseCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'response' && candidate.status === 'ready')
|
||||||
|
const requestCandidate = recorded.profileCandidates.find(candidate => candidate.direction === 'request' && candidate.transactionId === responseCandidate.transactionId)
|
||||||
|
assert.equal(requestCandidate.status, 'capture-required', JSON.stringify(recorded.profileCandidates))
|
||||||
|
|
||||||
|
await controlPage.goto(`chrome-extension://${extensionId}/options.html?tabId=${tabId}#gateway`)
|
||||||
|
await controlPage.evaluate(() => {
|
||||||
|
globalThis.captureActions = []
|
||||||
|
const send = chrome.runtime.sendMessage.bind(chrome.runtime)
|
||||||
|
chrome.runtime.sendMessage = (...args) => { globalThis.captureActions.push(args[0]); return send(...args) }
|
||||||
|
})
|
||||||
|
const traceIndex = recorded.traces.findIndex(trace => trace.id === responseCandidate.traceId)
|
||||||
|
await controlPage.locator('.recording-traces button').nth(traceIndex).click()
|
||||||
|
await controlPage.locator(`[data-event-id="${responseCandidate.source.eventId}"]`).click()
|
||||||
|
await controlPage.getByRole('button', { name: '继续捕获请求方向', exact: true }).click()
|
||||||
|
await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'armed')
|
||||||
|
let submitError
|
||||||
|
const submit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 45_000 }).catch(error => { submitError = error })
|
||||||
|
let profiles
|
||||||
|
try {
|
||||||
|
profiles = await waitFor(controlPage, 'transform.profile.list', {}, values => values.some(profile => profile.request.enabled && profile.response.enabled), 30_000)
|
||||||
|
} catch (error) {
|
||||||
|
const status = await extensionRequest(controlPage, 'deep.capture.status', target)
|
||||||
|
if (status.pause) status.pause.frames = status.pause.frames.map(({ scopes, ...frame }) => frame)
|
||||||
|
const actions = await controlPage.evaluate(() => globalThis.captureActions.filter(item => /create|start|save/.test(item.action)))
|
||||||
|
throw new Error(`${error.message}\n${JSON.stringify(status)}\n${JSON.stringify(actions)}\n${await controlPage.locator('body').innerText()}`)
|
||||||
|
}
|
||||||
|
await submit
|
||||||
|
if (submitError) throw submitError
|
||||||
|
assert.equal(completedPosts, 1, 'the capture submission must not reach the server')
|
||||||
|
assert.equal(blockedPosts, 1, 'the browser must confirm cancellation of the capture submission')
|
||||||
|
const profile = profiles.find(value => value.request.enabled && value.response.enabled)
|
||||||
|
assert.equal(profiles.length, 1, 'both directions must share one gateway')
|
||||||
|
|
||||||
|
for (const plaintext of [
|
||||||
|
{ username: 'audit-new-user', password: 'new-wrong-password' },
|
||||||
|
{ username: 'audit-second-user', password: 'second-wrong-password' },
|
||||||
|
]) {
|
||||||
|
const packet = { method: 'POST', url: targetURL, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: Buffer.from(JSON.stringify(plaintext)).toString('base64') }
|
||||||
|
const encrypted = await extensionRequest(controlPage, 'transform.execute', { profileId: profile.id, direction: 'request', packet })
|
||||||
|
const envelope = JSON.parse(Buffer.from(encrypted.bodyBase64, 'base64').toString())
|
||||||
|
assert.deepEqual(decrypt(envelope), plaintext, 'the complete new input must be encrypted')
|
||||||
|
const response = await fetch(targetURL, transformedFetchOptions(encrypted, packet.headers))
|
||||||
|
const wireResponse = await response.text()
|
||||||
|
const decrypted = await extensionRequest(controlPage, 'transform.execute', {
|
||||||
|
profileId: profile.id, direction: 'response', packet: { ...packet, bodyBase64: Buffer.from(wireResponse).toString('base64') },
|
||||||
|
})
|
||||||
|
assert.deepEqual(JSON.parse(Buffer.from(decrypted.bodyBase64, 'base64').toString()), decrypt(JSON.parse(wireResponse)))
|
||||||
|
}
|
||||||
|
assert.equal(completedPosts, 1, 'local gateway replay must not send browser requests')
|
||||||
|
assert.equal(await targetPage.locator('#username').inputValue(), 'audit-original')
|
||||||
|
assert.equal(await targetPage.locator('#password').inputValue(), 'original-wrong-password')
|
||||||
|
// Also pause inside the native Fetch boundary, where changing window.fetch is too late.
|
||||||
|
await extensionRequest(controlPage, 'deep.capture.start', {
|
||||||
|
...target, matcher: { kind: 'request', urlPattern: targetURL, frameHints: requestCandidate.capturePlan.frameHints },
|
||||||
|
})
|
||||||
|
const boundaryBlocked = targetPage.waitForEvent('requestfailed', {
|
||||||
|
predicate: request => request.url() === targetURL && request.failure()?.errorText.includes('BLOCKED_BY_CLIENT'),
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
void boundaryBlocked.catch(() => undefined)
|
||||||
|
const boundarySubmit = targetPage.locator('button[type=submit]').click({ noWaitAfter: true, timeout: 30_000 }).catch(error => { submitError = error })
|
||||||
|
const paused = await waitFor(controlPage, 'deep.capture.status', target, status => status.state === 'paused' && !status.pause.collecting)
|
||||||
|
assert.equal(paused.pause.automaticCapture.state, 'ready')
|
||||||
|
const boundaryCallable = await extensionRequest(controlPage, 'callable.create', {
|
||||||
|
...target, source: 'deep-capture', strategy: 'request-transaction',
|
||||||
|
callFrameId: paused.pause.automaticCapture.frameId, candidateId: requestCandidate.id,
|
||||||
|
})
|
||||||
|
await boundarySubmit
|
||||||
|
await boundaryBlocked
|
||||||
|
if (submitError) throw submitError
|
||||||
|
assert.equal(completedPosts, 1)
|
||||||
|
assert.equal(blockedPosts, 2)
|
||||||
|
await extensionRequest(controlPage, 'callable.delete', { ...target, callableId: boundaryCallable.id })
|
||||||
|
console.log('Login gateway passed: one recording, automatic request capture, browser-confirmed cancellation, one bidirectional profile, two distinct plaintexts and real server response decryption.')
|
||||||
|
} finally { await harness?.close() }
|
||||||
@@ -484,6 +484,8 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
|||||||
.recording-pipeline-step button > span:nth-child(2) { min-width: 0; }
|
.recording-pipeline-step button > span:nth-child(2) { min-width: 0; }
|
||||||
.recording-pipeline-step small, .recording-pipeline-step strong, .recording-pipeline-step em, .recording-pipeline-step b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.recording-pipeline-step small, .recording-pipeline-step strong, .recording-pipeline-step em, .recording-pipeline-step b { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.recording-pipeline-step small { color: var(--muted); font-size: 10px; font-weight: 650; }
|
.recording-pipeline-step small { color: var(--muted); font-size: 10px; font-weight: 650; }
|
||||||
|
.recording-pipeline-step small.is-request { color: var(--warning); }
|
||||||
|
.recording-pipeline-step small.is-response { color: var(--primary); }
|
||||||
.recording-pipeline-step strong { margin-top: 2px; font-size: var(--text-sm); font-weight: 650; }
|
.recording-pipeline-step strong { margin-top: 2px; font-size: var(--text-sm); font-weight: 650; }
|
||||||
.recording-pipeline-step em { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
.recording-pipeline-step em { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); font-style: normal; }
|
||||||
.recording-pipeline-step b { margin-top: 4px; color: var(--warning); font-size: 10px; font-weight: 650; }
|
.recording-pipeline-step b { margin-top: 4px; color: var(--warning); font-size: 10px; font-weight: 650; }
|
||||||
@@ -517,6 +519,12 @@ input[type='checkbox'] { width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
|
|||||||
.recording-evidence { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
.recording-evidence { border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
.recording-evidence summary { padding: 9px 0; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
|
.recording-evidence summary { padding: 9px 0; color: var(--muted-strong); font-size: var(--text-sm); font-weight: 600; cursor: pointer; }
|
||||||
.recording-evidence[open] { padding-bottom: 10px; }
|
.recording-evidence[open] { padding-bottom: 10px; }
|
||||||
|
.recording-related-transform { padding: 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-subtle); }
|
||||||
|
.recording-related-transform > div { min-width: 0; display: flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||||
|
.recording-related-transform > div span { min-width: 0; }
|
||||||
|
.recording-related-transform strong, .recording-related-transform small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.recording-related-transform strong { color: var(--foreground); font-size: var(--text-sm); }
|
||||||
|
.recording-related-transform small { margin-top: 2px; color: var(--muted); font-size: var(--text-xs); }
|
||||||
.profile-inference { padding: 11px 0 0; display: grid; gap: 10px; border-top: 2px solid var(--primary); }
|
.profile-inference { padding: 11px 0 0; display: grid; gap: 10px; border-top: 2px solid var(--primary); }
|
||||||
.profile-inference.is-medium { border-top-color: var(--warning); }
|
.profile-inference.is-medium { border-top-color: var(--warning); }
|
||||||
.profile-inference.is-low { border-top-color: var(--border-strong); }
|
.profile-inference.is-low { border-top-color: var(--border-strong); }
|
||||||
|
|||||||
@@ -682,7 +682,6 @@ function GatewayWorkspace({
|
|||||||
run={run}
|
run={run}
|
||||||
gatewayShared={bridge.state === 'connected'}
|
gatewayShared={bridge.state === 'connected'}
|
||||||
onShareGateway={shareTransform}
|
onShareGateway={shareTransform}
|
||||||
initialMode="gateway"
|
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type PageRecorderBridgeResponse,
|
type PageRecorderBridgeResponse,
|
||||||
} from '@/features/browser-recording/bridge-protocol';
|
} from '@/features/browser-recording/bridge-protocol';
|
||||||
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
|
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
|
||||||
import { executeRequestTransaction, executeSideEffectFreeCallable } from '@/features/page-callable/request-transaction';
|
import { executeRequestTransaction, executeSideEffectFreeCallable, observeCallableInput } from '@/features/page-callable/request-transaction';
|
||||||
import { callableExecutionPolicy, settleCallableResult } from '@/features/page-callable/execution';
|
import { callableExecutionPolicy, settleCallableResult } from '@/features/page-callable/execution';
|
||||||
import {
|
import {
|
||||||
createCryptoAdapterRuntime,
|
createCryptoAdapterRuntime,
|
||||||
@@ -200,8 +200,13 @@ interface RecordedCallHandle {
|
|||||||
original: Function;
|
original: Function;
|
||||||
thisArg: unknown;
|
thisArg: unknown;
|
||||||
args: unknown[];
|
args: unknown[];
|
||||||
inputIndex: number;
|
replayInputs: Array<{
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
role: CallArgumentRole;
|
||||||
originalInput: unknown;
|
originalInput: unknown;
|
||||||
|
apply(args: unknown[], value: unknown): void;
|
||||||
|
}>;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
traceId?: string;
|
traceId?: string;
|
||||||
recordingId?: string;
|
recordingId?: string;
|
||||||
@@ -209,7 +214,6 @@ interface RecordedCallHandle {
|
|||||||
outputDataType?: string;
|
outputDataType?: string;
|
||||||
outputEncoding?: PageCallableMetadata['output']['encoding'];
|
outputEncoding?: PageCallableMetadata['output']['encoding'];
|
||||||
resultMode: 'sync' | 'promise';
|
resultMode: 'sync' | 'promise';
|
||||||
adaptInput(value: unknown): unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecorderController {
|
interface RecorderController {
|
||||||
@@ -485,6 +489,7 @@ export default defineUnlistedScript(() => {
|
|||||||
}
|
}
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
const inputIndex = plan.inputIndex;
|
const inputIndex = plan.inputIndex;
|
||||||
|
if (inputIndex >= 0) observeCallableInput(args[inputIndex]);
|
||||||
const callHandleId = plan.callableKind && inputIndex >= 0 ? registerHandle({
|
const callHandleId = plan.callableKind && inputIndex >= 0 ? registerHandle({
|
||||||
kind: plan.callableKind,
|
kind: plan.callableKind,
|
||||||
operation: `${plan.crypto.adapterId}.${plan.crypto.operation}`,
|
operation: `${plan.crypto.adapterId}.${plan.crypto.operation}`,
|
||||||
@@ -492,11 +497,17 @@ export default defineUnlistedScript(() => {
|
|||||||
original,
|
original,
|
||||||
thisArg,
|
thisArg,
|
||||||
args: [...args],
|
args: [...args],
|
||||||
inputIndex,
|
replayInputs: plan.replayInputs || [{
|
||||||
|
path: '$input',
|
||||||
|
name: 'data',
|
||||||
|
role: 'data',
|
||||||
originalInput: args[inputIndex],
|
originalInput: args[inputIndex],
|
||||||
|
apply: (nextArgs, value) => {
|
||||||
|
nextArgs[inputIndex] = (plan.adaptInput || ((input) => defaultAdaptInput(input, args[inputIndex])))(value);
|
||||||
|
},
|
||||||
|
}],
|
||||||
outputEncoding: plan.outputEncoding || plan.crypto.outputEncoding,
|
outputEncoding: plan.outputEncoding || plan.crypto.outputEncoding,
|
||||||
resultMode: operation.resultMode,
|
resultMode: operation.resultMode,
|
||||||
adaptInput: plan.adaptInput || ((value) => defaultAdaptInput(value, args[inputIndex])),
|
|
||||||
}) : undefined;
|
}) : undefined;
|
||||||
const item = observe(() => ({
|
const item = observe(() => ({
|
||||||
kind: 'crypto',
|
kind: 'crypto',
|
||||||
@@ -728,8 +739,13 @@ export default defineUnlistedScript(() => {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRecordedCallable(handle: RecordedCallHandle, name: string): PageCallableMetadata {
|
function createRecordedCallable(handle: RecordedCallHandle, name: string, inputPaths?: string[]): PageCallableMetadata {
|
||||||
const id = unique('callable');
|
const id = unique('callable');
|
||||||
|
const replayInputs = inputPaths?.length
|
||||||
|
? inputPaths.map((path) => handle.replayInputs.find((item) => item.path === path))
|
||||||
|
: [handle.replayInputs[0]];
|
||||||
|
if (replayInputs.some((item) => !item)) throw new Error('页面调用不支持请求的动态输入');
|
||||||
|
const resolvedInputs = replayInputs as RecordedCallHandle['replayInputs'];
|
||||||
const metadata: PageCallableMetadata = {
|
const metadata: PageCallableMetadata = {
|
||||||
id,
|
id,
|
||||||
name: name.trim().slice(0, 120) || handle.operation,
|
name: name.trim().slice(0, 120) || handle.operation,
|
||||||
@@ -740,15 +756,15 @@ export default defineUnlistedScript(() => {
|
|||||||
origin: location.origin,
|
origin: location.origin,
|
||||||
lifecycle: 'document',
|
lifecycle: 'document',
|
||||||
execution: callableExecutionPolicy(handle.resultMode),
|
execution: callableExecutionPolicy(handle.resultMode),
|
||||||
inputSlots: [{
|
inputSlots: resolvedInputs.map((input, index) => ({
|
||||||
id: 'data',
|
id: input.name,
|
||||||
name: 'data',
|
name: input.name,
|
||||||
index: 0,
|
index,
|
||||||
role: 'data',
|
role: input.role,
|
||||||
dataType: dataType(handle.originalInput),
|
dataType: dataType(input.originalInput),
|
||||||
required: true,
|
required: true,
|
||||||
retained: false,
|
retained: false,
|
||||||
}],
|
})),
|
||||||
output: {
|
output: {
|
||||||
dataType: handle.outputDataType || 'unknown',
|
dataType: handle.outputDataType || 'unknown',
|
||||||
encoding: handle.outputEncoding || 'auto',
|
encoding: handle.outputEncoding || 'auto',
|
||||||
@@ -767,9 +783,9 @@ export default defineUnlistedScript(() => {
|
|||||||
pageCallableRegistry().set(id, {
|
pageCallableRegistry().set(id, {
|
||||||
metadata,
|
metadata,
|
||||||
invoke(values) {
|
invoke(values) {
|
||||||
if (!values.length) throw new Error('页面函数缺少 data 参数');
|
if (values.length < resolvedInputs.length) throw new Error(`页面函数需要 ${resolvedInputs.length} 个动态参数`);
|
||||||
const args = [...handle.args];
|
const args = [...handle.args];
|
||||||
args[handle.inputIndex] = handle.adaptInput(values[0]);
|
resolvedInputs.forEach((input, index) => input.apply(args, values[index]));
|
||||||
return Reflect.apply(handle.original, handle.thisArg, args);
|
return Reflect.apply(handle.original, handle.thisArg, args);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -786,6 +802,11 @@ export default defineUnlistedScript(() => {
|
|||||||
logicalInput: values[0],
|
logicalInput: values[0],
|
||||||
invoke: (context) => entry.invoke(values, context),
|
invoke: (context) => entry.invoke(values, context),
|
||||||
timeoutMs: entry.metadata.execution.timeoutMs,
|
timeoutMs: entry.metadata.execution.timeoutMs,
|
||||||
|
observeInputs: () => {
|
||||||
|
const recording = active || Boolean(deepBreakMatcher);
|
||||||
|
cryptoAdapterRuntime.start();
|
||||||
|
return () => { if (!recording) cryptoAdapterRuntime.stop(); };
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: entry.metadata.kind === 'business-closure' || entry.metadata.kind === 'global-function'
|
: entry.metadata.kind === 'business-closure' || entry.metadata.kind === 'global-function'
|
||||||
? await executeSideEffectFreeCallable(() => entry.invoke(values), entry.metadata.execution)
|
? await executeSideEffectFreeCallable(() => entry.invoke(values), entry.metadata.execution)
|
||||||
@@ -941,7 +962,11 @@ export default defineUnlistedScript(() => {
|
|||||||
const callHandleId = String(input.callHandleId || '');
|
const callHandleId = String(input.callHandleId || '');
|
||||||
const handle = handles.get(callHandleId);
|
const handle = handles.get(callHandleId);
|
||||||
if (!handle) throw new Error('加解密调用句柄不存在或已经失效');
|
if (!handle) throw new Error('加解密调用句柄不存在或已经失效');
|
||||||
return createRecordedCallable(handle, String(input.name || handle.operation));
|
return createRecordedCallable(
|
||||||
|
handle,
|
||||||
|
String(input.name || handle.operation),
|
||||||
|
Array.isArray(input.dynamicInputPaths) ? input.dynamicInputPaths.map(String) : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (command === 'callable.list') return callableMetadata();
|
if (command === 'callable.list') return callableMetadata();
|
||||||
if (command === 'callable.execute') {
|
if (command === 'callable.execute') {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('wxt/browser', () => {
|
|||||||
const event = { addListener: vi.fn() };
|
const event = { addListener: vi.fn() };
|
||||||
return {
|
return {
|
||||||
browser: {
|
browser: {
|
||||||
|
storage: {},
|
||||||
tabs: { onRemoved: event, onCreated: event },
|
tabs: { onRemoved: event, onCreated: event },
|
||||||
webNavigation: {
|
webNavigation: {
|
||||||
onBeforeNavigate: event,
|
onBeforeNavigate: event,
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ import {
|
|||||||
compileGuidedTransform,
|
compileGuidedTransform,
|
||||||
parseGuidedTransform,
|
parseGuidedTransform,
|
||||||
} from '@/features/browser-transform/guided';
|
} from '@/features/browser-transform/guided';
|
||||||
import { createBrowserTransformProfileInput } from '@/features/browser-transform/profile-draft';
|
import {
|
||||||
|
createBrowserTransformProfileInput,
|
||||||
|
pairedBrowserTransformCandidate,
|
||||||
|
} from '@/features/browser-transform/profile-draft';
|
||||||
import { validateBrowserTransformProfile } from '@/features/browser-transform/service';
|
import { validateBrowserTransformProfile } from '@/features/browser-transform/service';
|
||||||
import { executePageCallable, listPageCallables } from '@/features/page-callable/service';
|
import { executePageCallable, listPageCallables } from '@/features/page-callable/service';
|
||||||
import { browserTransformProfileInputSchema } from '@/protocol/transform';
|
import { browserTransformProfileInputSchema } from '@/protocol/transform';
|
||||||
import { ExtensionError } from '@/shared/errors';
|
import { ExtensionError } from '@/shared/errors';
|
||||||
import { createOpaqueId } from '@/shared/id';
|
import { createOpaqueId } from '@/shared/id';
|
||||||
|
import { eventMatcher } from '@/features/deep-capture/matcher';
|
||||||
|
import { createCapturedPageCallable, deepCaptureStatus, detachDeepCapture, startDeepCapture, type DeepCaptureOwner } from '@/features/deep-capture/service';
|
||||||
|
import { withPageNetworkGuard } from '@/features/page-callable/network-guard';
|
||||||
|
import { actOnPageNode, capturePageContext } from '@/features/page-context/service';
|
||||||
|
import { beginPageDialogCapture, endPageDialogCapture } from '@/features/page-context/dialogs';
|
||||||
import {
|
import {
|
||||||
BROWSER_TRANSFORM_AGENT_CONTRACT_VERSION,
|
BROWSER_TRANSFORM_AGENT_CONTRACT_VERSION,
|
||||||
type ActiveTabInfo,
|
type ActiveTabInfo,
|
||||||
@@ -36,6 +44,7 @@ import {
|
|||||||
type BrowserTransformProfileValidationResult,
|
type BrowserTransformProfileValidationResult,
|
||||||
type BrowserTransformValidationDraft,
|
type BrowserTransformValidationDraft,
|
||||||
type BrowserTransformDirectionName,
|
type BrowserTransformDirectionName,
|
||||||
|
type BrowserDeepCaptureMatcher,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
|
|
||||||
const MAX_TRACE_EVENTS = 80;
|
const MAX_TRACE_EVENTS = 80;
|
||||||
@@ -67,6 +76,8 @@ let callableOutputStorageQueue: Promise<void> = Promise.resolve();
|
|||||||
interface BrowserProfileEvidenceReference {
|
interface BrowserProfileEvidenceReference {
|
||||||
candidate: BrowserProfileInferenceCandidate;
|
candidate: BrowserProfileInferenceCandidate;
|
||||||
requestEvent?: BrowserRecordingEvent;
|
requestEvent?: BrowserRecordingEvent;
|
||||||
|
matcher?: BrowserDeepCaptureMatcher;
|
||||||
|
triggerKey?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
@@ -136,7 +147,7 @@ function pruneProfileEvidence(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot): Promise<void> {
|
export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot, triggerKey?: string): Promise<void> {
|
||||||
if (!snapshot.profileCandidates.length) return;
|
if (!snapshot.profileCandidates.length) return;
|
||||||
const scoped = recordingSnapshotForScope(snapshot, false);
|
const scoped = recordingSnapshotForScope(snapshot, false);
|
||||||
const events = new Map(scoped.events.map((event) => [event.id, event]));
|
const events = new Map(scoped.events.map((event) => [event.id, event]));
|
||||||
@@ -147,6 +158,8 @@ export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnap
|
|||||||
stored[candidate.id] = {
|
stored[candidate.id] = {
|
||||||
candidate,
|
candidate,
|
||||||
requestEvent: events.get(candidate.request.eventId),
|
requestEvent: events.get(candidate.request.eventId),
|
||||||
|
matcher: eventMatcher(events.get(candidate.capturePlan?.matcherEventId || candidate.source.eventId), candidate),
|
||||||
|
triggerKey: triggerKey || stored[candidate.id]?.triggerKey,
|
||||||
createdAt,
|
createdAt,
|
||||||
expiresAt: createdAt + PROFILE_EVIDENCE_TTL_MS,
|
expiresAt: createdAt + PROFILE_EVIDENCE_TTL_MS,
|
||||||
};
|
};
|
||||||
@@ -194,12 +207,14 @@ export async function resolveBrowserProfileCaptureTransaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function callableAnalysis(candidate: BrowserProfileInferenceCandidate): BrowserTransformCallableAnalysis {
|
function callableAnalysis(candidate: BrowserProfileInferenceCandidate): BrowserTransformCallableAnalysis {
|
||||||
|
const sources = [candidate.source, ...candidate.sources]
|
||||||
|
.filter((source, index, items) => items.findIndex((item) => item.eventId === source.eventId) === index);
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
traceId: candidate.traceId,
|
traceId: candidate.traceId,
|
||||||
confidence: { ...candidate.confidence },
|
confidence: { ...candidate.confidence },
|
||||||
flow: candidate.flow.slice(0, 32),
|
flow: candidate.flow.slice(0, 32),
|
||||||
operations: candidate.sources.slice(0, 16).map((source) => ({
|
operations: sources.slice(0, 16).map((source) => ({
|
||||||
operation: source.operation,
|
operation: source.operation,
|
||||||
destination: source.destination,
|
destination: source.destination,
|
||||||
crypto: source.crypto ? structuredClone(source.crypto) : undefined,
|
crypto: source.crypto ? structuredClone(source.crypto) : undefined,
|
||||||
@@ -223,6 +238,13 @@ async function resolveStagedProfileCandidate(
|
|||||||
return reference.candidate;
|
return reference.candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolvePairedStagedProfileCandidate(
|
||||||
|
candidate: BrowserProfileInferenceCandidate,
|
||||||
|
): Promise<BrowserProfileInferenceCandidate | undefined> {
|
||||||
|
const references = Object.values(pruneProfileEvidence(await readStoredProfileEvidence()));
|
||||||
|
return pairedBrowserTransformCandidate(references.map((item) => item.candidate), candidate, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveBrowserProfileCallableAnalysis(
|
export async function resolveBrowserProfileCallableAnalysis(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
candidateId: string,
|
candidateId: string,
|
||||||
@@ -1072,6 +1094,21 @@ function originOf(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function callableMatchesCandidate(
|
||||||
|
callable: BrowserPageCallable,
|
||||||
|
candidate: BrowserProfileInferenceCandidate,
|
||||||
|
): boolean {
|
||||||
|
const sources = [candidate.source, ...candidate.sources]
|
||||||
|
.filter((source, index, items) => items.findIndex((item) => item.eventId === source.eventId) === index);
|
||||||
|
const sourceEventIds = new Set(sources.map((source) => source.eventId));
|
||||||
|
if (callable.provenance.eventId) return sourceEventIds.has(callable.provenance.eventId);
|
||||||
|
const analysis = callable.provenance.analysis;
|
||||||
|
return analysis?.traceId === candidate.traceId && sources.every((source) => (
|
||||||
|
analysis.operations.some((operation) => operation.operation === source.operation
|
||||||
|
&& operation.destination === source.destination)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export async function proposeBrowserTransformProfile(
|
export async function proposeBrowserTransformProfile(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
candidateId: string,
|
candidateId: string,
|
||||||
@@ -1088,8 +1125,41 @@ export async function proposeBrowserTransformProfile(
|
|||||||
]);
|
]);
|
||||||
const evidence = await resolveProfileEvidence(snapshot, target, candidateId);
|
const evidence = await resolveProfileEvidence(snapshot, target, candidateId);
|
||||||
const candidate = evidence.candidate;
|
const candidate = evidence.candidate;
|
||||||
|
const pairedCandidate = pairedBrowserTransformCandidate(snapshot.profileCandidates, candidate, true)
|
||||||
|
|| await resolvePairedStagedProfileCandidate(candidate);
|
||||||
const recordedCallable = callables.find((item) => item.id === callableId);
|
const recordedCallable = callables.find((item) => item.id === callableId);
|
||||||
if (!recordedCallable) throw new ExtensionError('callable_unavailable', `页面函数已经失效: ${callableId}`);
|
if (!recordedCallable) throw new ExtensionError('callable_unavailable', `页面函数已经失效: ${callableId}`);
|
||||||
|
if (candidate.status !== 'ready' && recordedCallable.kind === 'recorded-call') {
|
||||||
|
throw new ExtensionError(
|
||||||
|
'gateway_capture_required',
|
||||||
|
candidate.missing[0]?.label || '当前候选依赖完整业务流程,不能直接复用单个页面加解密调用',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const recordedPairedCallable = pairedCandidate
|
||||||
|
? callables.find((item) => item.id !== recordedCallable.id && callableMatchesCandidate(item, pairedCandidate))
|
||||||
|
: undefined;
|
||||||
|
if (pairedCandidate && pairedCandidate.status !== 'ready' && recordedPairedCallable?.kind === 'recorded-call') {
|
||||||
|
throw new ExtensionError(
|
||||||
|
'gateway_capture_required',
|
||||||
|
pairedCandidate.missing[0]?.label || `配对的${pairedCandidate.direction === 'request' ? '请求' : '响应'}方向尚未完成捕获`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pairedCandidate && !recordedPairedCallable) {
|
||||||
|
throw new ExtensionError(
|
||||||
|
'gateway_pair_incomplete',
|
||||||
|
`已经识别同一事务的${pairedCandidate.direction === 'request' ? '请求' : '响应'}方向,但对应页面函数尚未创建`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pairedObservation = pairedCandidate?.direction === 'request' && recordedPairedCallable
|
||||||
|
? await callableOutputObservation(recordedPairedCallable)
|
||||||
|
: undefined;
|
||||||
|
const pairedCallable = pairedObservation && recordedPairedCallable
|
||||||
|
? promoteObservedEnvelopeCallable(
|
||||||
|
recordedPairedCallable,
|
||||||
|
snapshot.events.find((item) => item.id === pairedCandidate!.request.eventId),
|
||||||
|
pairedObservation.objectKeys,
|
||||||
|
)
|
||||||
|
: recordedPairedCallable;
|
||||||
const observation = candidate.direction === 'request'
|
const observation = candidate.direction === 'request'
|
||||||
? await callableOutputObservation(recordedCallable)
|
? await callableOutputObservation(recordedCallable)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1099,7 +1169,7 @@ export async function proposeBrowserTransformProfile(
|
|||||||
if (!sameEvidenceTarget(candidate, target)) {
|
if (!sameEvidenceTarget(candidate, target)) {
|
||||||
throw new ExtensionError('target_denied', '自动推断候选不属于当前共享页面');
|
throw new ExtensionError('target_denied', '自动推断候选不属于当前共享页面');
|
||||||
}
|
}
|
||||||
if (callable.provenance.traceId && callable.provenance.traceId !== candidate.traceId) {
|
if (!callableMatchesCandidate(callable, candidate)) {
|
||||||
throw new ExtensionError('profile_evidence_mismatch', '页面函数与自动推断候选不属于同一条业务 Trace');
|
throw new ExtensionError('profile_evidence_mismatch', '页面函数与自动推断候选不属于同一条业务 Trace');
|
||||||
}
|
}
|
||||||
const pageUrl = frame?.url || callable.origin;
|
const pageUrl = frame?.url || callable.origin;
|
||||||
@@ -1116,7 +1186,14 @@ export async function proposeBrowserTransformProfile(
|
|||||||
lastAccessed: tab.lastAccessed,
|
lastAccessed: tab.lastAccessed,
|
||||||
};
|
};
|
||||||
const requestEvent = evidence.requestEvent;
|
const requestEvent = evidence.requestEvent;
|
||||||
let profile = createBrowserTransformProfileInput(tabInfo, requestEvent, callable, candidate, inputPaths ? undefined : packet);
|
let profile = createBrowserTransformProfileInput(
|
||||||
|
tabInfo,
|
||||||
|
requestEvent,
|
||||||
|
callable,
|
||||||
|
candidate,
|
||||||
|
inputPaths ? undefined : packet,
|
||||||
|
pairedCandidate && pairedCallable ? { candidate: pairedCandidate, callable: pairedCallable } : undefined,
|
||||||
|
);
|
||||||
profile = {
|
profile = {
|
||||||
...profile,
|
...profile,
|
||||||
name: name || profile.name,
|
name: name || profile.name,
|
||||||
@@ -1193,15 +1270,17 @@ export async function validateBrowserTransformProposal(
|
|||||||
comparisonMode: 'structure' | 'exact' = 'structure',
|
comparisonMode: 'structure' | 'exact' = 'structure',
|
||||||
candidateId?: string,
|
candidateId?: string,
|
||||||
): Promise<BrowserTransformProfileValidationResult> {
|
): Promise<BrowserTransformProfileValidationResult> {
|
||||||
const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet);
|
|
||||||
const generated = applyTransformExecution(packet, execution);
|
|
||||||
const candidate = candidateId
|
const candidate = candidateId
|
||||||
? (await resolveProfileEvidence(
|
? (await resolveProfileEvidence(
|
||||||
await getBrowserRecording(normalized.target, 500, false),
|
await getBrowserRecording(profile.target, 500, false),
|
||||||
normalized.target,
|
profile.target,
|
||||||
candidateId,
|
candidateId,
|
||||||
)).candidate
|
)).candidate
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet, {
|
||||||
|
direction: candidate?.direction,
|
||||||
|
});
|
||||||
|
const generated = applyTransformExecution(packet, execution);
|
||||||
if (candidate && !sameEvidenceTarget(candidate, normalized.target)) {
|
if (candidate && !sameEvidenceTarget(candidate, normalized.target)) {
|
||||||
throw new ExtensionError('profile_evidence_mismatch', '验证候选不属于明文网关绑定的页面');
|
throw new ExtensionError('profile_evidence_mismatch', '验证候选不属于明文网关绑定的页面');
|
||||||
}
|
}
|
||||||
@@ -1237,6 +1316,7 @@ export async function validateBrowserTransformProposal(
|
|||||||
id: validationDraft.id,
|
id: validationDraft.id,
|
||||||
createdAt: validationDraft.createdAt,
|
createdAt: validationDraft.createdAt,
|
||||||
expiresAt: validationDraft.expiresAt,
|
expiresAt: validationDraft.expiresAt,
|
||||||
|
directions: { request: normalizedProfile.request.enabled, response: normalizedProfile.response.enabled },
|
||||||
} : undefined,
|
} : undefined,
|
||||||
next: comparison
|
next: comparison
|
||||||
? comparison.equivalent
|
? comparison.equivalent
|
||||||
@@ -1275,34 +1355,107 @@ export async function validateInferredBrowserTransformProfile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preparingTabs = new Set<number>();
|
||||||
|
|
||||||
|
interface PreparationCapture {
|
||||||
|
owner: DeepCaptureOwner;
|
||||||
|
trigger?: { captureId: string; nodeId: string };
|
||||||
|
authorize(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureMissingProfileCallable(
|
||||||
|
target: BrowserTarget,
|
||||||
|
candidate: BrowserProfileInferenceCandidate,
|
||||||
|
options?: PreparationCapture,
|
||||||
|
): Promise<BrowserPageCallable> {
|
||||||
|
if (candidate.status !== 'capture-required' || !options) {
|
||||||
|
throw new ExtensionError('gateway_capture_required', candidate.missing[0]?.label || '候选缺少可安全捕获的业务边界');
|
||||||
|
}
|
||||||
|
options.authorize();
|
||||||
|
const reference = pruneProfileEvidence(await readStoredProfileEvidence())[candidate.id];
|
||||||
|
if (!reference?.matcher) throw new ExtensionError('gateway_capture_required', '候选缺少断点证据,请重新执行 browser.crypto.inspect');
|
||||||
|
let trigger = options.trigger;
|
||||||
|
if (!trigger && reference.triggerKey) {
|
||||||
|
const context = await capturePageContext({ includeDom: true }, target);
|
||||||
|
const nodes = context.document.interactive.filter((node) => node.semanticKey === reference.triggerKey);
|
||||||
|
if (nodes.length === 1) trigger = { captureId: context.captureId, nodeId: nodes[0].nodeId };
|
||||||
|
}
|
||||||
|
if (!trigger) throw new ExtensionError('gateway_trigger_required', '无法唯一定位原操作,请用 browser.context 获取新的触发节点,并向 browser.transform.prepare 传入 trigger');
|
||||||
|
const current = await deepCaptureStatus(target, options.owner);
|
||||||
|
if (['attached', 'armed', 'paused'].includes(current.state)) throw new ExtensionError('capture_busy', '当前页面已有深度捕获会话,请先完成或释放它');
|
||||||
|
const transaction = candidate.direction === 'request' ? (await resolveBrowserProfileCaptureContext(target, candidate.id)).transaction : undefined;
|
||||||
|
return withPageNetworkGuard(target, transaction?.prerequisites || [], async () => {
|
||||||
|
const dialogOwned = await beginPageDialogCapture(target);
|
||||||
|
let started = false;
|
||||||
|
try {
|
||||||
|
await startDeepCapture(target, reference.matcher!, options.owner);
|
||||||
|
started = true;
|
||||||
|
await actOnPageNode(trigger!.captureId, trigger!.nodeId, 'click', target);
|
||||||
|
const deadline = Math.min(Date.now() + 15_000, options.owner.kind === 'grant' ? options.owner.expiresAt : Infinity);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const status = await deepCaptureStatus(target, options.owner);
|
||||||
|
if (status.state === 'paused' && status.pause && !status.pause.collecting) {
|
||||||
|
const automatic = status.pause.automaticCapture;
|
||||||
|
if (automatic?.state !== 'ready' || !automatic.frameId) {
|
||||||
|
throw new ExtensionError('gateway_capture_ambiguous', automatic?.reason || '暂停现场没有唯一可复用的业务函数', { automaticCapture: automatic });
|
||||||
|
}
|
||||||
|
const analysis = callableAnalysis(candidate);
|
||||||
|
if (automatic.strategy === 'request-transaction') {
|
||||||
|
if (!transaction) throw new ExtensionError('gateway_capture_required', '该方向没有请求事务证据');
|
||||||
|
return await createCapturedPageCallable(target, automatic.frameId, { strategy: 'request-transaction', transaction, analysis }, options.owner);
|
||||||
|
}
|
||||||
|
return await createCapturedPageCallable(target, automatic.frameId, { strategy: 'selected-frame', analysis }, options.owner);
|
||||||
|
}
|
||||||
|
if (!['armed', 'paused', 'attached'].includes(status.state)) throw new ExtensionError('gateway_capture_failed', status.error || '捕获会话已结束,未获取业务函数');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new ExtensionError('gateway_capture_timeout', '重触发操作后未命中预期业务边界,请检查当前页面状态');
|
||||||
|
} finally {
|
||||||
|
try { if (started) await detachDeepCapture(target, options.owner); }
|
||||||
|
finally { await endPageDialogCapture(target, dialogOwned); }
|
||||||
|
}
|
||||||
|
}, candidate.direction === 'request' ? candidate.request.url : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareCapturedBrowserTransformProfile(
|
export async function prepareCapturedBrowserTransformProfile(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
candidateId: string,
|
candidateId: string,
|
||||||
packet: BrowserTransformPacket,
|
packet: BrowserTransformPacket,
|
||||||
inputPaths?: string[],
|
inputPaths?: string[],
|
||||||
name?: string,
|
name?: string,
|
||||||
|
capture?: PreparationCapture,
|
||||||
): Promise<BrowserTransformProfileValidationResult> {
|
): Promise<BrowserTransformProfileValidationResult> {
|
||||||
|
if (preparingTabs.has(target.tabId)) throw new ExtensionError('capture_busy', '当前标签页正在准备网关,请等待该操作完成');
|
||||||
|
preparingTabs.add(target.tabId);
|
||||||
|
try {
|
||||||
const candidate = await resolveStagedProfileCandidate(target, candidateId);
|
const candidate = await resolveStagedProfileCandidate(target, candidateId);
|
||||||
const source = [candidate.source, ...candidate.sources]
|
const pairedCandidate = await resolvePairedStagedProfileCandidate(candidate);
|
||||||
.find((item) => item.callHandleId);
|
const existingCallables = await listPageCallables(target);
|
||||||
if (!source?.callHandleId) {
|
const ensureCallable = async (item: BrowserProfileInferenceCandidate) => {
|
||||||
throw new ExtensionError(
|
const source = [item.source, ...item.sources].find((value) => value.callHandleId) || item.source;
|
||||||
'gateway_capture_required',
|
const existing = existingCallables.find((callable) => callableMatchesCandidate(callable, item)
|
||||||
'本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查',
|
&& (item.status === 'ready'
|
||||||
);
|
? callable.inputSlots.filter((slot) => !slot.retained).length === (source.dynamicInputPaths?.length || 1)
|
||||||
}
|
: callable.kind !== 'recorded-call'));
|
||||||
const existing = (await listPageCallables(target))
|
if (existing) return existing;
|
||||||
.find((item) => item.provenance.eventId === source.eventId);
|
if (item.status !== 'ready') return captureMissingProfileCallable(target, item, capture);
|
||||||
const callable = existing || await createRecordedPageCallable(target, {
|
if (!source.callHandleId) throw new ExtensionError('gateway_capture_required', '候选缺少可复用的页面调用句柄,请重新执行 browser.crypto.inspect');
|
||||||
|
return createRecordedPageCallable(target, {
|
||||||
callHandleId: source.callHandleId,
|
callHandleId: source.callHandleId,
|
||||||
name: name || candidate.summary.slice(0, 120) || 'Captured page transform',
|
name: name || item.summary.slice(0, 120) || 'Captured page transform',
|
||||||
|
dynamicInputPaths: source.dynamicInputPaths,
|
||||||
});
|
});
|
||||||
return validateInferredBrowserTransformProfile(
|
};
|
||||||
target,
|
// Retain an already-recorded opposite direction before re-triggering the page.
|
||||||
candidate.id,
|
const directions = [candidate, pairedCandidate].filter((item): item is BrowserProfileInferenceCandidate => Boolean(item))
|
||||||
callable.id,
|
.sort((left, right) => Number(right.status === 'ready') - Number(left.status === 'ready'));
|
||||||
packet,
|
let callable: BrowserPageCallable | undefined;
|
||||||
inputPaths,
|
for (const direction of directions) {
|
||||||
name,
|
const created = await ensureCallable(direction);
|
||||||
);
|
if (direction.id === candidate.id) callable = created;
|
||||||
|
}
|
||||||
|
return await validateInferredBrowserTransformProfile(target, candidate.id, callable!.id, packet, inputPaths, name);
|
||||||
|
} finally {
|
||||||
|
preparingTabs.delete(target.tabId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,15 @@ describe('page crypto adapters', () => {
|
|||||||
const CBC = {};
|
const CBC = {};
|
||||||
const Pkcs7 = {};
|
const Pkcs7 = {};
|
||||||
const parsed: string[] = [];
|
const parsed: string[] = [];
|
||||||
|
const hexParsed: string[] = [];
|
||||||
const cryptoJs = {
|
const cryptoJs = {
|
||||||
AES: { encrypt() { return 'cipher'; } },
|
AES: { encrypt() { return 'cipher'; }, decrypt() { return 'plain'; } },
|
||||||
mode: { CBC },
|
mode: { CBC },
|
||||||
pad: { Pkcs7 },
|
pad: { Pkcs7 },
|
||||||
enc: { Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } } },
|
enc: {
|
||||||
|
Base64: { parse(value: string) { parsed.push(value); return { wordArray: value }; } },
|
||||||
|
Hex: { parse(value: string) { hexParsed.push(value); return { hexWordArray: value }; } },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
|
const scope = { window: { CryptoJS: cryptoJs } as unknown as Window } satisfies CryptoAdapterScope;
|
||||||
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
|
const encrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.encrypt');
|
||||||
@@ -117,6 +121,28 @@ describe('page crypto adapters', () => {
|
|||||||
]);
|
]);
|
||||||
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
|
expect(plan?.adaptInput?.(new Uint8Array([4, 5, 6]))).toEqual({ wordArray: 'base64:4,5,6' });
|
||||||
expect(parsed).toEqual(['base64:4,5,6']);
|
expect(parsed).toEqual(['base64:4,5,6']);
|
||||||
|
|
||||||
|
const decrypt = cryptoJsAdapter.discover(scope).find((item) => item.operation === 'AES.decrypt');
|
||||||
|
const decryptPlan = decrypt?.describe(cryptoJs.AES, [
|
||||||
|
'cipher',
|
||||||
|
{ sigBytes: 16 },
|
||||||
|
{ mode: CBC, padding: Pkcs7, iv: { sigBytes: 16 } },
|
||||||
|
], toolkit());
|
||||||
|
expect(decryptPlan?.crypto.outputEncoding).toBe('hex');
|
||||||
|
expect(decryptPlan?.outputEncoding).toBe('hex');
|
||||||
|
expect(decryptPlan?.replayInputs?.map((input) => input.path)).toEqual([
|
||||||
|
'$input', '$input.key', '$input.iv',
|
||||||
|
]);
|
||||||
|
const replayArgs: unknown[] = ['old-cipher', { oldKey: true }, { mode: CBC, padding: Pkcs7, iv: { oldIv: true } }];
|
||||||
|
decryptPlan?.replayInputs?.[0].apply(replayArgs, 'new-cipher');
|
||||||
|
decryptPlan?.replayInputs?.[1].apply(replayArgs, '00112233');
|
||||||
|
decryptPlan?.replayInputs?.[2].apply(replayArgs, 'aabbccdd');
|
||||||
|
expect(replayArgs).toEqual([
|
||||||
|
'new-cipher',
|
||||||
|
{ hexWordArray: '00112233' },
|
||||||
|
{ mode: CBC, padding: Pkcs7, iv: { hexWordArray: 'aabbccdd' } },
|
||||||
|
]);
|
||||||
|
expect(hexParsed).toEqual(['00112233', 'aabbccdd']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retains only bounded JSEncrypt receiver metadata', () => {
|
it('retains only bounded JSEncrypt receiver metadata', () => {
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export interface CryptoAdapterInvocationPlan {
|
|||||||
arguments: BrowserRecordingCallArgument[];
|
arguments: BrowserRecordingCallArgument[];
|
||||||
callableKind?: CallableOperationKind;
|
callableKind?: CallableOperationKind;
|
||||||
outputEncoding?: BrowserPageCallableValueEncoding;
|
outputEncoding?: BrowserPageCallableValueEncoding;
|
||||||
|
replayInputs?: Array<{
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
role: BrowserRecordingCallArgument['role'];
|
||||||
|
originalInput: unknown;
|
||||||
|
apply(args: unknown[], value: unknown): void;
|
||||||
|
}>;
|
||||||
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
inputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
||||||
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
outputEvidence?(value: unknown): BrowserRecordingValueEvidence[];
|
||||||
outputError?(value: unknown): string | undefined;
|
outputError?(value: unknown): string | undefined;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ function describe(
|
|||||||
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS || {};
|
const cryptoJs = (scope.window as unknown as { CryptoJS?: Record<string, unknown> }).CryptoJS || {};
|
||||||
const normalized = path.toLowerCase();
|
const normalized = path.toLowerCase();
|
||||||
const encrypting = normalized.includes('encrypt');
|
const encrypting = normalized.includes('encrypt');
|
||||||
|
const decrypting = normalized.includes('decrypt');
|
||||||
|
const outputEncoding = encrypting ? 'base64' : decrypting ? 'hex' : 'auto';
|
||||||
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
|
const options = normalized.includes('encrypt') || normalized.includes('decrypt')
|
||||||
? optionsMetadata(cryptoJs, args[2], toolkit)
|
? optionsMetadata(cryptoJs, args[2], toolkit)
|
||||||
: {};
|
: {};
|
||||||
@@ -68,6 +70,33 @@ function describe(
|
|||||||
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
|
else if (normalized.includes('pbkdf2') || normalized.includes('evpkdf')) roles = ['data', 'salt', 'options'];
|
||||||
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
|
else if (normalized.includes('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options'];
|
||||||
const callableKind = callableOperationKind(path);
|
const callableKind = callableOperationKind(path);
|
||||||
|
const adaptData = (value: unknown) => {
|
||||||
|
const originalInput = args[0];
|
||||||
|
if (originalInput && typeof originalInput === 'object'
|
||||||
|
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
|
||||||
|
const bytes = toolkit.bytesForInput(value);
|
||||||
|
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
|
||||||
|
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
|
||||||
|
}
|
||||||
|
return toolkit.defaultAdaptInput(value, originalInput);
|
||||||
|
};
|
||||||
|
const adaptWordArray = (value: unknown, originalInput: unknown) => {
|
||||||
|
if (!originalInput || typeof originalInput !== 'object'
|
||||||
|
|| typeof (originalInput as { sigBytes?: unknown }).sigBytes !== 'number') {
|
||||||
|
return toolkit.defaultAdaptInput(value, originalInput);
|
||||||
|
}
|
||||||
|
const enc = (cryptoJs as {
|
||||||
|
enc?: {
|
||||||
|
Hex?: { parse?(input: string): unknown };
|
||||||
|
Base64?: { parse?(input: string): unknown };
|
||||||
|
};
|
||||||
|
}).enc;
|
||||||
|
if (typeof value === 'string' && /^[0-9a-f]+$/i.test(value) && value.length % 2 === 0
|
||||||
|
&& typeof enc?.Hex?.parse === 'function') return enc.Hex.parse(value);
|
||||||
|
const bytes = toolkit.bytesForInput(value);
|
||||||
|
if (bytes && typeof enc?.Base64?.parse === 'function') return enc.Base64.parse(toolkit.bytesToBase64(bytes));
|
||||||
|
return toolkit.defaultAdaptInput(value, originalInput);
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
crypto: {
|
crypto: {
|
||||||
adapterId: cryptoJsManifest.id,
|
adapterId: cryptoJsManifest.id,
|
||||||
@@ -78,12 +107,31 @@ function describe(
|
|||||||
mode: options.mode,
|
mode: options.mode,
|
||||||
padding: options.padding,
|
padding: options.padding,
|
||||||
inputEncoding: 'auto',
|
inputEncoding: 'auto',
|
||||||
outputEncoding: encrypting ? 'base64' : 'auto',
|
outputEncoding,
|
||||||
state: { model: 'stateless', phase: 'one-shot' },
|
state: { model: 'stateless', phase: 'one-shot' },
|
||||||
},
|
},
|
||||||
inputIndex: 0,
|
inputIndex: 0,
|
||||||
callableKind,
|
callableKind,
|
||||||
outputEncoding: encrypting ? 'base64' : 'auto',
|
outputEncoding,
|
||||||
|
replayInputs: decrypting ? [
|
||||||
|
{
|
||||||
|
path: '$input', name: 'data', role: 'data', originalInput: args[0],
|
||||||
|
apply: (nextArgs, value) => { nextArgs[0] = adaptData(value); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '$input.key', name: 'key', role: 'key', originalInput: args[1],
|
||||||
|
apply: (nextArgs, value) => { nextArgs[1] = adaptWordArray(value, args[1]); },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '$input.iv', name: 'iv', role: 'iv', originalInput: ownValue(args[2], 'iv'),
|
||||||
|
apply: (nextArgs, value) => {
|
||||||
|
const options = nextArgs[2] && typeof nextArgs[2] === 'object'
|
||||||
|
? nextArgs[2] as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
nextArgs[2] = { ...options, iv: adaptWordArray(value, ownValue(args[2], 'iv')) };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] : undefined,
|
||||||
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
|
arguments: args.slice(0, 8).map((value, index) => toolkit.argument(
|
||||||
index,
|
index,
|
||||||
roles[index] || 'unknown',
|
roles[index] || 'unknown',
|
||||||
@@ -121,14 +169,7 @@ function describe(
|
|||||||
return output.slice(0, 48);
|
return output.slice(0, 48);
|
||||||
},
|
},
|
||||||
adaptInput(value) {
|
adaptInput(value) {
|
||||||
const originalInput = args[0];
|
return adaptData(value);
|
||||||
if (originalInput && typeof originalInput === 'object'
|
|
||||||
&& typeof (originalInput as { sigBytes?: unknown }).sigBytes === 'number') {
|
|
||||||
const bytes = toolkit.bytesForInput(value);
|
|
||||||
const encoder = (cryptoJs as { enc?: { Base64?: { parse?(input: string): unknown } } }).enc?.Base64;
|
|
||||||
if (bytes && typeof encoder?.parse === 'function') return encoder.parse(toolkit.bytesToBase64(bytes));
|
|
||||||
}
|
|
||||||
return toolkit.defaultAdaptInput(value, originalInput);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('atomic page crypto inspection', () => {
|
|||||||
}],
|
}],
|
||||||
traces: [], links: [], callables: [], profileCandidates: [{
|
traces: [], links: [], callables: [], profileCandidates: [{
|
||||||
id: 'candidate-1', direction: 'request', summary: 'login request',
|
id: 'candidate-1', direction: 'request', summary: 'login request',
|
||||||
|
status: 'ready',
|
||||||
confidence: { score: 0.95, level: 'high' },
|
confidence: { score: 0.95, level: 'high' },
|
||||||
source: { eventId: 'event-1', callHandleId: 'handle-1' },
|
source: { eventId: 'event-1', callHandleId: 'handle-1' },
|
||||||
sources: [],
|
sources: [],
|
||||||
@@ -117,6 +118,35 @@ describe('atomic page crypto inspection', () => {
|
|||||||
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
|
expect(fixture.context).toHaveBeenCalledWith({ includeDom: true }, { tabId: 7, frameId: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prepares a response-only protocol when no request transform was observed', async () => {
|
||||||
|
fixture.stopRecording.mockResolvedValueOnce({
|
||||||
|
status: { target: { tabId: 7, frameId: 0, documentId: 'doc-1' }, active: false, documentAvailable: true, count: 1, droppedCount: 0 },
|
||||||
|
events: [{
|
||||||
|
id: 'decrypt-1', sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
|
||||||
|
kind: 'crypto', operation: 'AES.decrypt', inputs: [], outputs: [], sensitiveCaptured: true,
|
||||||
|
}],
|
||||||
|
traces: [], links: [], callables: [], profileCandidates: [{
|
||||||
|
id: 'candidate-response', recordingId: 'recording-1', traceId: 'trace-1', direction: 'response',
|
||||||
|
status: 'ready', confidence: { score: 100, level: 'high' },
|
||||||
|
source: { eventId: 'decrypt-1', callHandleId: 'handle-1' }, sources: [],
|
||||||
|
request: { method: 'POST', url: 'https://example.test/api', bodyFormat: 'json', mappings: [] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const { inspectPageCryptoOperation } = await import('./inspect');
|
||||||
|
|
||||||
|
const result = await inspectPageCryptoOperation(
|
||||||
|
{ tabId: 7, frameId: 0, documentId: 'doc-1' },
|
||||||
|
{ captureId: 'capture-1', nodeId: 'n1', settleMs: 250 },
|
||||||
|
{ grantId: 'paired', expiresAt: Date.now() + 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.gatewayPreparation).toMatchObject({
|
||||||
|
state: 'ready',
|
||||||
|
direction: 'response',
|
||||||
|
directions: { request: { status: 'absent' }, response: { candidateId: 'candidate-response', status: 'ready' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
|
it('waits for a delayed request instead of treating an empty capture as idle', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
restorePageDialogCapture,
|
restorePageDialogCapture,
|
||||||
} from '@/features/page-context/dialogs';
|
} from '@/features/page-context/dialogs';
|
||||||
import { ExtensionError } from '@/shared/errors';
|
import { ExtensionError } from '@/shared/errors';
|
||||||
|
import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
|
||||||
import type {
|
import type {
|
||||||
BrowserRecordingEvent,
|
BrowserRecordingEvent,
|
||||||
BrowserRecordingSnapshot,
|
BrowserRecordingSnapshot,
|
||||||
@@ -219,11 +220,23 @@ export async function inspectPageCryptoOperation(
|
|||||||
if (!snapshot || !action) {
|
if (!snapshot || !action) {
|
||||||
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
|
throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查');
|
||||||
}
|
}
|
||||||
await stageBrowserProfileEvidence(snapshot);
|
await stageBrowserProfileEvidence(snapshot, action.node.semanticKey);
|
||||||
const preparation = snapshot.profileCandidates
|
const preparation = snapshot.profileCandidates
|
||||||
.filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources]
|
.filter((candidate) => [candidate.source, ...candidate.sources]
|
||||||
.some((source) => Boolean(source.callHandleId)))
|
.some((source) => Boolean(source.callHandleId)))
|
||||||
.sort((left, right) => right.confidence.score - left.confidence.score)[0];
|
.sort((left, right) => Number(right.direction === 'request') - Number(left.direction === 'request')
|
||||||
|
|| right.confidence.score - left.confidence.score)[0];
|
||||||
|
const pairedPreparation = preparation
|
||||||
|
? pairedBrowserTransformCandidate(snapshot.profileCandidates, preparation, true)
|
||||||
|
: undefined;
|
||||||
|
const directions = [preparation, pairedPreparation].filter(
|
||||||
|
(candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate),
|
||||||
|
);
|
||||||
|
const requestPreparation = directions.find((candidate) => candidate.direction === 'request');
|
||||||
|
const responsePreparation = directions.find((candidate) => candidate.direction === 'response');
|
||||||
|
const preparationReady = Boolean(preparation?.status === 'ready'
|
||||||
|
&& directions.every((candidate) => candidate.status === 'ready'
|
||||||
|
&& [candidate.source, ...candidate.sources].some((source) => Boolean(source.callHandleId))));
|
||||||
const evidence = summarizeCryptoInspection(snapshot, requests);
|
const evidence = summarizeCryptoInspection(snapshot, requests);
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -243,17 +256,27 @@ export async function inspectPageCryptoOperation(
|
|||||||
},
|
},
|
||||||
postAction,
|
postAction,
|
||||||
gatewayPreparation: preparation ? {
|
gatewayPreparation: preparation ? {
|
||||||
state: 'ready',
|
state: preparationReady ? 'ready' : 'capture-required',
|
||||||
candidateId: preparation.id,
|
candidateId: preparation.id,
|
||||||
direction: preparation.direction,
|
direction: preparation.direction,
|
||||||
confidence: preparation.confidence,
|
confidence: preparation.confidence,
|
||||||
|
directions: {
|
||||||
|
request: requestPreparation
|
||||||
|
? { candidateId: requestPreparation.id, status: requestPreparation.status }
|
||||||
|
: { status: 'absent' },
|
||||||
|
response: responsePreparation
|
||||||
|
? { candidateId: responsePreparation.id, status: responsePreparation.status }
|
||||||
|
: { status: 'absent' },
|
||||||
|
},
|
||||||
request: {
|
request: {
|
||||||
method: preparation.request.method,
|
method: preparation.request.method,
|
||||||
url: preparation.request.url,
|
url: preparation.request.url,
|
||||||
bodyFormat: preparation.request.bodyFormat,
|
bodyFormat: preparation.request.bodyFormat,
|
||||||
destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean),
|
destinations: preparation.request.mappings.map((mapping) => mapping.destination).filter(Boolean),
|
||||||
},
|
},
|
||||||
next: '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;不要再调用 recording、callable、debugger 或 profile 底层能力',
|
next: preparationReady
|
||||||
|
? '需要明文 HTTP 测试时,直接调用 browser.transform.prepare;同一事务的请求与响应会编译进一个 Profile'
|
||||||
|
: '调用 browser.transform.prepare,插件将自动重触发本次操作、捕获缺失的业务方向并验证完整网关;不需要打开插件 UI',
|
||||||
} : {
|
} : {
|
||||||
state: 'unavailable',
|
state: 'unavailable',
|
||||||
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
|
next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import { inferBrowserTransformProfiles } from './inference';
|
import { inferBrowserTransformProfiles } from './inference';
|
||||||
import { buildRecordingLinks } from '@/features/browser-recording/timeline';
|
import { buildRecordingLinks } from '@/features/browser-recording/timeline';
|
||||||
|
import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft';
|
||||||
|
|
||||||
function event(overrides: Partial<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): BrowserRecordingEvent {
|
function event(overrides: Partial<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): BrowserRecordingEvent {
|
||||||
return {
|
return {
|
||||||
@@ -606,4 +607,112 @@ describe('browser profile inference', () => {
|
|||||||
}));
|
}));
|
||||||
expect(candidate.aiContext.requiredDecision).toBe('none');
|
expect(candidate.aiContext.requiredDecision).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replays response ciphertext, key, and iv as dynamic CryptoJS decrypt inputs', () => {
|
||||||
|
const response = event({
|
||||||
|
id: 'encrypted-response-dynamic', sequence: 1, kind: 'fetch', operation: 'response',
|
||||||
|
direction: 'receive', method: 'POST', url: 'https://example.test/crypto/login', statusCode: 200,
|
||||||
|
outputs: [
|
||||||
|
{ path: '$body:json.message', fingerprint: 'cipher', encoding: 'text', byteLength: 88 },
|
||||||
|
{ path: '$body:json.key', fingerprint: 'key-hex', encoding: 'hex', byteLength: 32 },
|
||||||
|
{ path: '$body:json.iv', fingerprint: 'iv-hex', encoding: 'hex', byteLength: 32 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const key = event({
|
||||||
|
id: 'parse-key', sequence: 2, kind: 'transform', operation: 'Hex.parse',
|
||||||
|
inputs: [{ path: '$input', fingerprint: 'key-hex', encoding: 'hex', byteLength: 32 }],
|
||||||
|
outputs: [{ path: '$output', fingerprint: 'key-word-array', encoding: 'bytes', byteLength: 16 }],
|
||||||
|
});
|
||||||
|
const iv = event({
|
||||||
|
id: 'parse-iv', sequence: 3, kind: 'transform', operation: 'Hex.parse',
|
||||||
|
inputs: [{ path: '$input', fingerprint: 'iv-hex', encoding: 'hex', byteLength: 32 }],
|
||||||
|
outputs: [{ path: '$output', fingerprint: 'iv-word-array', encoding: 'bytes', byteLength: 16 }],
|
||||||
|
});
|
||||||
|
const decrypt = event({
|
||||||
|
id: 'decrypt-dynamic-response', sequence: 4, kind: 'crypto', operation: 'AES.decrypt',
|
||||||
|
crypto: cryptoJsAESDecrypt,
|
||||||
|
callHandleId: 'decrypt-dynamic-handle', callableCapable: true, arguments: safeArguments,
|
||||||
|
inputs: [
|
||||||
|
{ path: '$input', fingerprint: 'cipher', encoding: 'text', byteLength: 88 },
|
||||||
|
{ path: '$input.key', fingerprint: 'key-word-array', encoding: 'bytes', byteLength: 16 },
|
||||||
|
{ path: '$input.iv', fingerprint: 'iv-word-array', encoding: 'bytes', byteLength: 16 },
|
||||||
|
],
|
||||||
|
outputs: [{ path: '$output', fingerprint: 'plain', encoding: 'hex', byteLength: 42 }],
|
||||||
|
});
|
||||||
|
const events = [response, key, iv, decrypt];
|
||||||
|
const [candidate] = inferBrowserTransformProfiles({
|
||||||
|
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
||||||
|
events,
|
||||||
|
links: buildRecordingLinks(events),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(candidate).toMatchObject({
|
||||||
|
direction: 'response',
|
||||||
|
status: 'ready',
|
||||||
|
source: { dynamicInputPaths: ['$input', '$input.key', '$input.iv'] },
|
||||||
|
request: {
|
||||||
|
mappings: [
|
||||||
|
{ destination: 'body.message' },
|
||||||
|
{ destination: 'body.key' },
|
||||||
|
{ destination: 'body.iv' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(candidate.missing).toEqual([]);
|
||||||
|
expect(candidate.aiContext.requiredDecision).toBe('none');
|
||||||
|
|
||||||
|
const withoutCipherLink = inferBrowserTransformProfiles({
|
||||||
|
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
||||||
|
events: [
|
||||||
|
{ ...response, outputs: response.outputs.filter((output) => output.path !== '$body:json.message') },
|
||||||
|
key,
|
||||||
|
iv,
|
||||||
|
{ ...decrypt, inputs: decrypt.inputs.filter((input) => input.path !== '$input') },
|
||||||
|
],
|
||||||
|
links: buildRecordingLinks([
|
||||||
|
{ ...response, outputs: response.outputs.filter((output) => output.path !== '$body:json.message') },
|
||||||
|
key,
|
||||||
|
iv,
|
||||||
|
{ ...decrypt, inputs: decrypt.inputs.filter((input) => input.path !== '$input') },
|
||||||
|
]),
|
||||||
|
})[0];
|
||||||
|
expect(withoutCipherLink).toMatchObject({ direction: 'response', status: 'capture-required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs request and response candidates by the browser network transaction', () => {
|
||||||
|
const encrypt = event({
|
||||||
|
id: 'encrypt-transaction', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
|
||||||
|
callHandleId: 'encrypt-handle', callableCapable: true, arguments: safeArguments,
|
||||||
|
inputs: [{ path: '$input', fingerprint: 'plain-request', encoding: 'text', byteLength: 20 }],
|
||||||
|
outputs: [{ path: '$output:string', fingerprint: 'cipher-request', encoding: 'text', byteLength: 44 }],
|
||||||
|
});
|
||||||
|
const request = event({
|
||||||
|
id: 'request-transaction', sequence: 2, kind: 'fetch', operation: 'request', direction: 'send',
|
||||||
|
channelId: 'fetch-transaction-1', method: 'POST', url: 'https://example.test/login',
|
||||||
|
inputs: [{ path: '$body:json.message', fingerprint: 'cipher-request', encoding: 'text', byteLength: 44 }],
|
||||||
|
});
|
||||||
|
const response = event({
|
||||||
|
id: 'response-transaction', sequence: 3, kind: 'fetch', operation: 'response', direction: 'receive',
|
||||||
|
channelId: 'fetch-transaction-1', method: 'POST', url: 'https://example.test/login', statusCode: 200,
|
||||||
|
outputs: [{ path: '$body:json.message', fingerprint: 'cipher-response', encoding: 'text', byteLength: 44 }],
|
||||||
|
});
|
||||||
|
const decrypt = event({
|
||||||
|
id: 'decrypt-transaction', sequence: 4, kind: 'crypto', operation: 'AES.decrypt', crypto: cryptoJsAESDecrypt,
|
||||||
|
callHandleId: 'decrypt-handle', callableCapable: true, arguments: safeArguments,
|
||||||
|
inputs: [{ path: '$input', fingerprint: 'cipher-response', encoding: 'text', byteLength: 44 }],
|
||||||
|
outputs: [{ path: '$output', fingerprint: 'plain-response', encoding: 'hex', byteLength: 20 }],
|
||||||
|
});
|
||||||
|
const events = [encrypt, request, response, decrypt];
|
||||||
|
const candidates = inferBrowserTransformProfiles({
|
||||||
|
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
||||||
|
events,
|
||||||
|
links: buildRecordingLinks(events),
|
||||||
|
});
|
||||||
|
const requestCandidate = candidates.find((candidate) => candidate.direction === 'request')!;
|
||||||
|
const responseCandidate = candidates.find((candidate) => candidate.direction === 'response')!;
|
||||||
|
|
||||||
|
expect(requestCandidate.transactionId).toBe('fetch-transaction-1');
|
||||||
|
expect(responseCandidate.transactionId).toBe('fetch-transaction-1');
|
||||||
|
expect(pairedBrowserTransformCandidate(candidates, requestCandidate)?.id).toBe(responseCandidate.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -562,6 +562,7 @@ function buildCandidate(
|
|||||||
id: candidateId,
|
id: candidateId,
|
||||||
recordingId: request.recordingId,
|
recordingId: request.recordingId,
|
||||||
traceId: request.traceId,
|
traceId: request.traceId,
|
||||||
|
transactionId: request.channelId,
|
||||||
target: { ...target },
|
target: { ...target },
|
||||||
direction: 'request',
|
direction: 'request',
|
||||||
request: {
|
request: {
|
||||||
@@ -698,6 +699,7 @@ function buildUnknownBoundaryCandidate(
|
|||||||
id: candidateId,
|
id: candidateId,
|
||||||
recordingId: request.recordingId,
|
recordingId: request.recordingId,
|
||||||
traceId: request.traceId,
|
traceId: request.traceId,
|
||||||
|
transactionId: request.channelId,
|
||||||
target: { ...target },
|
target: { ...target },
|
||||||
direction: 'request',
|
direction: 'request',
|
||||||
request: {
|
request: {
|
||||||
@@ -812,6 +814,7 @@ function buildRequestGraphCandidate(
|
|||||||
id: candidateId,
|
id: candidateId,
|
||||||
recordingId: request.recordingId,
|
recordingId: request.recordingId,
|
||||||
traceId: request.traceId,
|
traceId: request.traceId,
|
||||||
|
transactionId: request.channelId,
|
||||||
target: { ...target },
|
target: { ...target },
|
||||||
direction: 'request',
|
direction: 'request',
|
||||||
request: {
|
request: {
|
||||||
@@ -871,6 +874,7 @@ function buildRequestGraphCandidate(
|
|||||||
interface LinkedResponseSource {
|
interface LinkedResponseSource {
|
||||||
event: BrowserRecordingEvent;
|
event: BrowserRecordingEvent;
|
||||||
links: BrowserRecordingLink[];
|
links: BrowserRecordingLink[];
|
||||||
|
inputChains: BrowserRecordingLink[][];
|
||||||
stateLinks: BrowserRecordingLink[];
|
stateLinks: BrowserRecordingLink[];
|
||||||
stateEvents: BrowserRecordingEvent[];
|
stateEvents: BrowserRecordingEvent[];
|
||||||
}
|
}
|
||||||
@@ -896,12 +900,23 @@ function linkedResponseSources(
|
|||||||
const chain = [...current.links, link];
|
const chain = [...current.links, link];
|
||||||
if (isReverseCryptoEvent(consumer)) {
|
if (isReverseCryptoEvent(consumer)) {
|
||||||
const previous = output.get(consumer.id);
|
const previous = output.get(consumer.id);
|
||||||
if (!previous || chain.length < previous.links.length) {
|
if (!previous) {
|
||||||
output.set(consumer.id, {
|
output.set(consumer.id, {
|
||||||
event: consumer,
|
event: consumer,
|
||||||
links: chain,
|
links: chain,
|
||||||
|
inputChains: [chain],
|
||||||
...stateSequence(consumer, eventsById, incoming),
|
...stateSequence(consumer, eventsById, incoming),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const terminal = chain.at(-1)!;
|
||||||
|
if (!previous.inputChains.some((item) => item.at(-1)?.toPath === terminal.toPath)) {
|
||||||
|
previous.inputChains.push(chain);
|
||||||
|
}
|
||||||
|
const previousTarget = previous.links.at(-1)?.toPath;
|
||||||
|
if ((terminal.toPath === '$input' && previousTarget !== '$input')
|
||||||
|
|| (terminal.toPath === previousTarget && chain.length < previous.links.length)) {
|
||||||
|
previous.links = chain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const depth = current.depth + 1;
|
const depth = current.depth + 1;
|
||||||
@@ -920,12 +935,24 @@ function buildResponseCandidate(
|
|||||||
response: BrowserRecordingEvent,
|
response: BrowserRecordingEvent,
|
||||||
source: LinkedResponseSource,
|
source: LinkedResponseSource,
|
||||||
): BrowserProfileInferenceCandidate {
|
): BrowserProfileInferenceCandidate {
|
||||||
const firstLink = source.links[0];
|
const dataChain = source.inputChains.find((chain) => chain.at(-1)?.toPath === '$input');
|
||||||
|
const primaryLinks = dataChain || source.links;
|
||||||
|
const firstLink = dataChain?.[0];
|
||||||
const { destination: inputPath, serialization } = requestMapping(firstLink?.fromPath);
|
const { destination: inputPath, serialization } = requestMapping(firstLink?.fromPath);
|
||||||
const bodyFormat = responseBodyFormat(response, [serialization]);
|
const bodyFormat = responseBodyFormat(response, [serialization]);
|
||||||
const exact = source.links.length > 0 && source.links.every((link) => link.confidence === 'exact');
|
const responseLinks = [...new Map(source.inputChains.flat().map((link) => [link.id, link])).values()];
|
||||||
|
const dynamicInputChains = source.inputChains.filter((chain) => chain.at(-1)?.toPath !== '$input');
|
||||||
|
const dynamicInputs = dynamicInputChains
|
||||||
|
.map((chain) => chain.at(-1)?.toPath)
|
||||||
|
.filter((path): path is string => Boolean(path && path !== '$input'));
|
||||||
|
const replayInputPaths = ['$input', ...dynamicInputs];
|
||||||
|
const supportsDynamicInputs = source.event.crypto?.adapterId === 'cryptojs'
|
||||||
|
&& source.event.crypto.operation.toLowerCase().includes('decrypt')
|
||||||
|
&& dynamicInputs.every((path) => path === '$input.key' || path === '$input.iv');
|
||||||
|
const exact = responseLinks.length > 0 && responseLinks.every((link) => link.confidence === 'exact');
|
||||||
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
|
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
|
||||||
const replayReady = exact && source.links.length === 1 && Boolean(inputPath) && hasCallable;
|
const replayReady = exact && dataChain?.length === 1
|
||||||
|
&& (dynamicInputs.length === 0 || supportsDynamicInputs) && Boolean(inputPath) && hasCallable;
|
||||||
const argumentRoles = source.event.arguments || [];
|
const argumentRoles = source.event.arguments || [];
|
||||||
const responseName = requestLabel(response);
|
const responseName = requestLabel(response);
|
||||||
const sourceName = sourceLabel(source.event);
|
const sourceName = sourceLabel(source.event);
|
||||||
@@ -938,7 +965,7 @@ function buildResponseCandidate(
|
|||||||
eventIds: [response.id],
|
eventIds: [response.id],
|
||||||
fromPath: firstLink?.fromPath,
|
fromPath: firstLink?.fromPath,
|
||||||
}];
|
}];
|
||||||
source.links.forEach((link, index) => evidence.push({
|
responseLinks.forEach((link, index) => evidence.push({
|
||||||
id: `evidence-response-link-${link.id || `${response.id}-${source.event.id}-${index}`}`,
|
id: `evidence-response-link-${link.id || `${response.id}-${source.event.id}-${index}`}`,
|
||||||
kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary',
|
kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary',
|
||||||
strength: link.confidence === 'exact' ? 'proven' : 'supported',
|
strength: link.confidence === 'exact' ? 'proven' : 'supported',
|
||||||
@@ -974,6 +1001,14 @@ function buildResponseCandidate(
|
|||||||
label: '页面仍保留本次解密调用的原函数、receiver 与固定参数模板',
|
label: '页面仍保留本次解密调用的原函数、receiver 与固定参数模板',
|
||||||
eventIds: [source.event.id],
|
eventIds: [source.event.id],
|
||||||
});
|
});
|
||||||
|
const responseMappings = (dataChain ? [dataChain, ...dynamicInputChains] : source.inputChains).map((chain) => {
|
||||||
|
const mapping = requestMapping(chain[0]?.fromPath);
|
||||||
|
return {
|
||||||
|
sourceEventId: source.event.id,
|
||||||
|
destination: mapping.destination,
|
||||||
|
serialization: mapping.serialization,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let score = 20;
|
let score = 20;
|
||||||
if (exact) score += 40;
|
if (exact) score += 40;
|
||||||
@@ -989,7 +1024,9 @@ function buildResponseCandidate(
|
|||||||
} else {
|
} else {
|
||||||
missing.push({
|
missing.push({
|
||||||
kind: 'business-callable',
|
kind: 'business-callable',
|
||||||
label: exact && inputPath
|
label: dynamicInputs.length && !supportsDynamicInputs
|
||||||
|
? `响应解密还依赖每次响应中的 ${dynamicInputs.map((path) => path.replace(/^\$input\.?/, '')).join('、')};需捕获上层业务函数以保留动态参数关系`
|
||||||
|
: exact && inputPath
|
||||||
? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系'
|
? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系'
|
||||||
: '响应字段与页面解密调用尚未形成可回放的直接值链,请继续捕获当前解密现场',
|
: '响应字段与页面解密调用尚未形成可回放的直接值链,请继续捕获当前解密现场',
|
||||||
action: 'capture-business-function',
|
action: 'capture-business-function',
|
||||||
@@ -1000,6 +1037,7 @@ function buildResponseCandidate(
|
|||||||
id: candidateId,
|
id: candidateId,
|
||||||
recordingId: response.recordingId,
|
recordingId: response.recordingId,
|
||||||
traceId: response.traceId,
|
traceId: response.traceId,
|
||||||
|
transactionId: response.channelId,
|
||||||
target: { ...target },
|
target: { ...target },
|
||||||
direction: 'response',
|
direction: 'response',
|
||||||
request: {
|
request: {
|
||||||
@@ -1009,7 +1047,7 @@ function buildResponseCandidate(
|
|||||||
bodyFormat,
|
bodyFormat,
|
||||||
destination: inputPath,
|
destination: inputPath,
|
||||||
serialization,
|
serialization,
|
||||||
mappings: [{ sourceEventId: source.event.id, destination: inputPath, serialization }],
|
mappings: responseMappings,
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
eventId: source.event.id,
|
eventId: source.event.id,
|
||||||
@@ -1017,6 +1055,7 @@ function buildResponseCandidate(
|
|||||||
operation: source.event.operation,
|
operation: source.event.operation,
|
||||||
crypto: source.event.crypto,
|
crypto: source.event.crypto,
|
||||||
callHandleId: source.event.callHandleId,
|
callHandleId: source.event.callHandleId,
|
||||||
|
dynamicInputPaths: replayReady ? replayInputPaths : undefined,
|
||||||
arguments: argumentRoles,
|
arguments: argumentRoles,
|
||||||
destination: inputPath,
|
destination: inputPath,
|
||||||
serialization,
|
serialization,
|
||||||
@@ -1027,6 +1066,7 @@ function buildResponseCandidate(
|
|||||||
operation: source.event.operation,
|
operation: source.event.operation,
|
||||||
crypto: source.event.crypto,
|
crypto: source.event.crypto,
|
||||||
callHandleId: source.event.callHandleId,
|
callHandleId: source.event.callHandleId,
|
||||||
|
dynamicInputPaths: replayReady ? replayInputPaths : undefined,
|
||||||
arguments: argumentRoles,
|
arguments: argumentRoles,
|
||||||
destination: inputPath,
|
destination: inputPath,
|
||||||
serialization,
|
serialization,
|
||||||
@@ -1038,7 +1078,7 @@ function buildResponseCandidate(
|
|||||||
: `已定位 ${responseName} 到 ${sourceName} 的响应解密链`,
|
: `已定位 ${responseName} 到 ${sourceName} 的响应解密链`,
|
||||||
flow: [
|
flow: [
|
||||||
inputPath ? `${responseName} · ${inputPath}` : responseName,
|
inputPath ? `${responseName} · ${inputPath}` : responseName,
|
||||||
...(source.links.length > 1 ? [`${source.links.length - 1} 个响应准备步骤`] : []),
|
...(primaryLinks.length > 1 ? [`${primaryLinks.length - 1} 个响应准备步骤`] : []),
|
||||||
sourceName,
|
sourceName,
|
||||||
'明文响应',
|
'明文响应',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
BrowserTransformWorkspace,
|
BrowserTransformWorkspace,
|
||||||
type BrowserTransformSuggestionSeed,
|
type BrowserTransformSuggestionSeed,
|
||||||
} from '@/features/browser-transform/BrowserTransformWorkspace';
|
} from '@/features/browser-transform/BrowserTransformWorkspace';
|
||||||
import { createBrowserTransformProfileInput } from '@/features/browser-transform/profile-draft';
|
import {
|
||||||
|
createBrowserTransformProfileInput,
|
||||||
|
pairedBrowserTransformCandidate,
|
||||||
|
type BrowserTransformProfileBinding,
|
||||||
|
} from '@/features/browser-transform/profile-draft';
|
||||||
|
import { browserGatewayNextStep, recordingEventDirection } from './presentation';
|
||||||
|
|
||||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||||
const DEEP_CAPTURE_AVAILABLE = !import.meta.env.FIREFOX;
|
const DEEP_CAPTURE_AVAILABLE = !import.meta.env.FIREFOX;
|
||||||
@@ -30,7 +35,6 @@ interface RecordingWorkspaceProps {
|
|||||||
run: RunTask;
|
run: RunTask;
|
||||||
gatewayShared: boolean;
|
gatewayShared: boolean;
|
||||||
onShareGateway: () => Promise<void>;
|
onShareGateway: () => Promise<void>;
|
||||||
initialMode?: 'gateway' | 'recording' | 'deep';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_LABELS: Record<BrowserRecordingEvent['kind'], string> = {
|
const KIND_LABELS: Record<BrowserRecordingEvent['kind'], string> = {
|
||||||
@@ -65,6 +69,14 @@ function confidenceLabel(candidate: BrowserProfileInferenceCandidate): string {
|
|||||||
return `${level}置信度 · ${candidate.confidence.score}`;
|
return `${level}置信度 · ${candidate.confidence.score}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function candidateStatusLabel(candidate?: BrowserProfileInferenceCandidate): string {
|
||||||
|
if (!candidate) return '未检测到';
|
||||||
|
if (candidate.status === 'ready') return '可直接生成';
|
||||||
|
if (candidate.status === 'capture-required') return '需要完整捕获';
|
||||||
|
if (candidate.status === 'mapping-required') return '需要确认映射';
|
||||||
|
return '证据不足';
|
||||||
|
}
|
||||||
|
|
||||||
function eventIcon(kind: BrowserRecordingEvent['kind']) {
|
function eventIcon(kind: BrowserRecordingEvent['kind']) {
|
||||||
if (kind === 'navigation') return <Navigation size={15} />;
|
if (kind === 'navigation') return <Navigation size={15} />;
|
||||||
if (kind === 'interaction') return <Radio size={15} />;
|
if (kind === 'interaction') return <Radio size={15} />;
|
||||||
@@ -183,9 +195,8 @@ export function RecordingWorkspace({
|
|||||||
run,
|
run,
|
||||||
gatewayShared,
|
gatewayShared,
|
||||||
onShareGateway,
|
onShareGateway,
|
||||||
initialMode = 'recording',
|
|
||||||
}: RecordingWorkspaceProps) {
|
}: RecordingWorkspaceProps) {
|
||||||
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>(initialMode);
|
const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording');
|
||||||
const [autoArmRequest, setAutoArmRequest] = useState(0);
|
const [autoArmRequest, setAutoArmRequest] = useState(0);
|
||||||
const [autoRecoveryRequest, setAutoRecoveryRequest] = useState(0);
|
const [autoRecoveryRequest, setAutoRecoveryRequest] = useState(0);
|
||||||
const [recoveryProfileId, setRecoveryProfileId] = useState('');
|
const [recoveryProfileId, setRecoveryProfileId] = useState('');
|
||||||
@@ -202,6 +213,9 @@ export function RecordingWorkspace({
|
|||||||
const [callableArguments, setCallableArguments] = useState('[]');
|
const [callableArguments, setCallableArguments] = useState('[]');
|
||||||
const [callableResult, setCallableResult] = useState<BrowserPageCallableExecution>();
|
const [callableResult, setCallableResult] = useState<BrowserPageCallableExecution>();
|
||||||
const [gatewaySuggestion, setGatewaySuggestion] = useState<BrowserTransformSuggestionSeed>();
|
const [gatewaySuggestion, setGatewaySuggestion] = useState<BrowserTransformSuggestionSeed>();
|
||||||
|
const [pendingGatewayBinding, setPendingGatewayBinding] = useState<BrowserTransformProfileBinding>();
|
||||||
|
const [captureCandidate, setCaptureCandidate] = useState<BrowserProfileInferenceCandidate>();
|
||||||
|
useEffect(() => { setCaptureCandidate(undefined); }, [tab?.id]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const tabId = tab?.id;
|
const tabId = tab?.id;
|
||||||
@@ -284,6 +298,8 @@ export function RecordingWorkspace({
|
|||||||
setSelectedTraceId('');
|
setSelectedTraceId('');
|
||||||
setSelectedEventId('');
|
setSelectedEventId('');
|
||||||
setCallableResult(undefined);
|
setCallableResult(undefined);
|
||||||
|
setPendingGatewayBinding(undefined);
|
||||||
|
setCaptureCandidate(undefined);
|
||||||
}, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链');
|
}, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链');
|
||||||
|
|
||||||
const stop = () => run(async () => {
|
const stop = () => run(async () => {
|
||||||
@@ -295,6 +311,8 @@ export function RecordingWorkspace({
|
|||||||
if (!recordingTarget) return;
|
if (!recordingTarget) return;
|
||||||
setSnapshot(await request('recording.clear', recordingTarget));
|
setSnapshot(await request('recording.clear', recordingTarget));
|
||||||
setCallableResult(undefined);
|
setCallableResult(undefined);
|
||||||
|
setPendingGatewayBinding(undefined);
|
||||||
|
setCaptureCandidate(undefined);
|
||||||
}, '录制与录制型页面函数已清空');
|
}, '录制与录制型页面函数已清空');
|
||||||
|
|
||||||
const createCallable = () => run(async () => {
|
const createCallable = () => run(async () => {
|
||||||
@@ -357,9 +375,22 @@ export function RecordingWorkspace({
|
|||||||
const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : [];
|
const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : [];
|
||||||
const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : [];
|
const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : [];
|
||||||
const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || [];
|
const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || [];
|
||||||
const selectedCandidate = traceCandidates.find((candidate) => (
|
const sourceCandidates = traceCandidates.filter((candidate) => candidate.source.eventId === selectedEventId);
|
||||||
candidate.sources.some((source) => source.eventId === selectedEventId) || candidate.request.eventId === selectedEventId
|
const selectedCandidate = sourceCandidates.length === 1 ? sourceCandidates[0] : undefined;
|
||||||
)) || traceCandidates[0];
|
const pairedCandidate = selectedCandidate
|
||||||
|
? pairedBrowserTransformCandidate(snapshot?.profileCandidates || [], selectedCandidate)
|
||||||
|
: undefined;
|
||||||
|
const boundaryCandidates = selectedCandidate ? [] : traceCandidates.filter((candidate) => candidate.request.eventId === selectedEventId);
|
||||||
|
const relatedCandidate = boundaryCandidates.length === 1 ? boundaryCandidates[0] : undefined;
|
||||||
|
const relatedSourceEvent = relatedCandidate
|
||||||
|
? snapshot?.events.find((event) => event.id === relatedCandidate.source.eventId)
|
||||||
|
: undefined;
|
||||||
|
const selectedEventDirection = selectedEvent
|
||||||
|
? recordingEventDirection(selectedEvent, traceCandidates)
|
||||||
|
: undefined;
|
||||||
|
const gatewayNextStep = selectedCandidate
|
||||||
|
? browserGatewayNextStep(selectedCandidate, pairedCandidate)
|
||||||
|
: undefined;
|
||||||
const candidateSourceEvent = selectedCandidate
|
const candidateSourceEvent = selectedCandidate
|
||||||
? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId)
|
? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -382,7 +413,9 @@ export function RecordingWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const continueInference = (candidate: BrowserProfileInferenceCandidate) => {
|
const continueInference = (candidate: BrowserProfileInferenceCandidate) => {
|
||||||
|
setCaptureCandidate(candidate);
|
||||||
setRecoveryProfileId('');
|
setRecoveryProfileId('');
|
||||||
|
setSelectedTraceId(candidate.traceId);
|
||||||
setSelectedEventId(candidate.capturePlan?.matcherEventId
|
setSelectedEventId(candidate.capturePlan?.matcherEventId
|
||||||
|| (candidate.sources.length > 1 ? candidate.request.eventId : candidate.source.eventId));
|
|| (candidate.sources.length > 1 ? candidate.request.eventId : candidate.source.eventId));
|
||||||
setAutoArmRequest((current) => current + 1);
|
setAutoArmRequest((current) => current + 1);
|
||||||
@@ -390,6 +423,7 @@ export function RecordingWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openRecovery = (profileId: string) => {
|
const openRecovery = (profileId: string) => {
|
||||||
|
setCaptureCandidate(undefined);
|
||||||
setRecoveryProfileId(profileId);
|
setRecoveryProfileId(profileId);
|
||||||
setAutoRecoveryRequest((current) => current + 1);
|
setAutoRecoveryRequest((current) => current + 1);
|
||||||
setWorkspaceMode('deep');
|
setWorkspaceMode('deep');
|
||||||
@@ -407,6 +441,37 @@ export function RecordingWorkspace({
|
|||||||
capturedSample?: CapturedCallableSample,
|
capturedSample?: CapturedCallableSample,
|
||||||
) => {
|
) => {
|
||||||
if (!tab) throw new Error('目标标签页已经关闭');
|
if (!tab) throw new Error('目标标签页已经关闭');
|
||||||
|
const binding: BrowserTransformProfileBinding = { candidate, callable };
|
||||||
|
const pair = pairedBrowserTransformCandidate(snapshot?.profileCandidates || [], candidate, true);
|
||||||
|
let pairedBinding = pendingGatewayBinding?.candidate.id === pair?.id ? pendingGatewayBinding : undefined;
|
||||||
|
if (!pairedBinding && pair) {
|
||||||
|
if (pair.status === 'capture-required') {
|
||||||
|
setPendingGatewayBinding(binding);
|
||||||
|
continueInference(pair);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pair.status !== 'ready') {
|
||||||
|
throw new Error(`已检测到${pair.direction === 'request' ? '请求' : '响应'}方向,但${candidateStatusLabel(pair)},不能静默保存为单向网关`);
|
||||||
|
}
|
||||||
|
const pairEvent = snapshot?.events.find((item) => item.id === pair.source.eventId);
|
||||||
|
if (!eventAvailableInDocument(pairEvent, currentDocumentId, documentAvailable) || !snapshot?.status.target) {
|
||||||
|
throw new Error('配对方向属于另一个页面文档,请返回对应页面现场后再生成');
|
||||||
|
}
|
||||||
|
const pairInputCount = pair.source.dynamicInputPaths?.length || 1;
|
||||||
|
let pairCallable = snapshot.callables.find((item) => item.provenance.eventId === pair.source.eventId
|
||||||
|
&& item.inputSlots.filter((slot) => !slot.retained).length === pairInputCount);
|
||||||
|
if (!pairCallable) {
|
||||||
|
if (!pair.source.callHandleId) throw new Error('配对方向没有可复用的页面调用句柄');
|
||||||
|
pairCallable = await request('callable.create', {
|
||||||
|
...snapshot.status.target,
|
||||||
|
source: 'recording',
|
||||||
|
callHandleId: pair.source.callHandleId,
|
||||||
|
name: `${pair.source.crypto?.algorithm || pair.source.crypto?.operation || pair.source.operation} 页面函数`,
|
||||||
|
dynamicInputPaths: pair.source.dynamicInputPaths,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pairedBinding = { candidate: pair, callable: pairCallable };
|
||||||
|
}
|
||||||
const sourceEvent = snapshot?.events.find((item) => item.id === candidate.source.eventId);
|
const sourceEvent = snapshot?.events.find((item) => item.id === candidate.source.eventId);
|
||||||
const boundaryEvent = snapshot?.events.find((item) => item.id === candidate.request.eventId);
|
const boundaryEvent = snapshot?.events.find((item) => item.id === candidate.request.eventId);
|
||||||
const profile = await request('transform.profile.save', createBrowserTransformProfileInput(
|
const profile = await request('transform.profile.save', createBrowserTransformProfileInput(
|
||||||
@@ -414,15 +479,21 @@ export function RecordingWorkspace({
|
|||||||
sourceEvent,
|
sourceEvent,
|
||||||
callable,
|
callable,
|
||||||
candidate,
|
candidate,
|
||||||
|
undefined,
|
||||||
|
pairedBinding,
|
||||||
));
|
));
|
||||||
|
const callables = [callable, pairedBinding?.callable].filter((item): item is BrowserPageCallable => Boolean(item));
|
||||||
|
const callableIds = new Set(callables.map((item) => item.id));
|
||||||
setSnapshot((current) => current ? {
|
setSnapshot((current) => current ? {
|
||||||
...current,
|
...current,
|
||||||
callables: [...current.callables.filter((item) => item.id !== callable.id), callable],
|
callables: [...current.callables.filter((item) => !callableIds.has(item.id)), ...callables],
|
||||||
} : current);
|
} : current);
|
||||||
|
setPendingGatewayBinding(undefined);
|
||||||
|
setCaptureCandidate(undefined);
|
||||||
setGatewaySuggestion((current) => ({
|
setGatewaySuggestion((current) => ({
|
||||||
revision: (current?.revision || 0) + 1,
|
revision: (current?.revision || 0) + 1,
|
||||||
candidate,
|
candidate,
|
||||||
callable,
|
callables,
|
||||||
profile,
|
profile,
|
||||||
sampleBody: capturedSample?.body || shortSample(candidate.direction === 'response' ? boundaryEvent : sourceEvent),
|
sampleBody: capturedSample?.body || shortSample(candidate.direction === 'response' ? boundaryEvent : sourceEvent),
|
||||||
sampleLabel: capturedSample?.label || (candidate.direction === 'response' && boundaryEvent
|
sampleLabel: capturedSample?.label || (candidate.direction === 'response' && boundaryEvent
|
||||||
@@ -447,17 +518,20 @@ export function RecordingWorkspace({
|
|||||||
if (!currentSnapshot) throw new Error('没有可用的录制现场');
|
if (!currentSnapshot) throw new Error('没有可用的录制现场');
|
||||||
const target = currentSnapshot.status.target;
|
const target = currentSnapshot.status.target;
|
||||||
if (!target) throw new Error('录制文档已经失效');
|
if (!target) throw new Error('录制文档已经失效');
|
||||||
let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId);
|
const inputCount = candidate.source.dynamicInputPaths?.length || 1;
|
||||||
|
let callable = currentSnapshot.callables.find((item) => item.provenance.eventId === candidate.source.eventId
|
||||||
|
&& item.inputSlots.filter((slot) => !slot.retained).length === inputCount);
|
||||||
if (!callable) {
|
if (!callable) {
|
||||||
callable = await request('callable.create', {
|
callable = await request('callable.create', {
|
||||||
...target,
|
...target,
|
||||||
source: 'recording',
|
source: 'recording',
|
||||||
callHandleId: candidate.source.callHandleId,
|
callHandleId: candidate.source.callHandleId,
|
||||||
name: `${candidate.source.crypto?.algorithm || candidate.source.crypto?.operation || candidate.source.operation} 页面函数`,
|
name: `${candidate.source.crypto?.algorithm || candidate.source.crypto?.operation || candidate.source.operation} 页面函数`,
|
||||||
|
dynamicInputPaths: candidate.source.dynamicInputPaths,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await openSuggestedGateway(candidate, callable);
|
await openSuggestedGateway(candidate, callable);
|
||||||
}, '已根据录制证据生成并保存明文网关');
|
}, '当前方向已完成;存在配对方向时将继续捕获并合并为一个网关');
|
||||||
|
|
||||||
return <section className="recording-section">
|
return <section className="recording-section">
|
||||||
<div className="recording-heading">
|
<div className="recording-heading">
|
||||||
@@ -521,11 +595,12 @@ export function RecordingWorkspace({
|
|||||||
{!traceEvents.length ? <div className="recording-column-empty">当前 Trace 没有事件</div> : traceEvents.map((event, index) => {
|
{!traceEvents.length ? <div className="recording-column-empty">当前 Trace 没有事件</div> : traceEvents.map((event, index) => {
|
||||||
const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id);
|
const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id);
|
||||||
const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable);
|
const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable);
|
||||||
|
const flowDirection = recordingEventDirection(event, traceCandidates);
|
||||||
return <div className={`recording-pipeline-step ${event.kind === 'navigation' ? 'is-navigation' : ''}`} key={event.id}>
|
return <div className={`recording-pipeline-step ${event.kind === 'navigation' ? 'is-navigation' : ''}`} key={event.id}>
|
||||||
<span className="recording-step-rail" aria-hidden="true"><i>{String(index + 1).padStart(2, '0')}</i>{index < traceEvents.length - 1 ? <span><ArrowDown size={11} /></span> : null}</span>
|
<span className="recording-step-rail" aria-hidden="true"><i>{String(index + 1).padStart(2, '0')}</i>{index < traceEvents.length - 1 ? <span><ArrowDown size={11} /></span> : null}</span>
|
||||||
<button data-event-id={event.id} className={`${event.id === selectedEventId ? 'is-selected' : ''} ${linked ? 'is-linked' : ''}`} onClick={() => setSelectedEventId(event.id)}>
|
<button data-event-id={event.id} className={`${event.id === selectedEventId ? 'is-selected' : ''} ${linked ? 'is-linked' : ''}`} onClick={() => setSelectedEventId(event.id)}>
|
||||||
<span className={`recording-event-icon kind-${event.kind}`}>{eventIcon(event.kind)}</span>
|
<span className={`recording-event-icon kind-${event.kind}`}>{eventIcon(event.kind)}</span>
|
||||||
<span><small>{KIND_LABELS[event.kind]}</small><strong>{eventTitle(event)}</strong><em>{eventSubtitle(event)}</em>{event.kind === 'navigation' ? <b>{navigationPhaseLabel(event)}</b> : null}</span>
|
<span><small className={flowDirection ? `is-${flowDirection}` : ''}>{flowDirection === 'request' ? '↑ 请求' : flowDirection === 'response' ? '↓ 响应' : KIND_LABELS[event.kind]}{flowDirection ? ` · ${KIND_LABELS[event.kind]}` : ''}</small><strong>{eventTitle(event)}</strong><em>{eventSubtitle(event)}</em>{event.kind === 'navigation' ? <b>{navigationPhaseLabel(event)}</b> : null}</span>
|
||||||
<span className="recording-event-meta">{event.callableCapable ? <i className={callableAvailable ? '' : 'is-history'}>{callableAvailable ? '当前可用' : '历史现场'}</i> : null}<time title={new Date(event.timestamp).toLocaleString()}>{relativeTime(event.timestamp, selectedTrace?.startedAt)}</time>{event.durationMs !== undefined ? <small>{event.durationMs.toFixed(1)} ms</small> : null}</span>
|
<span className="recording-event-meta">{event.callableCapable ? <i className={callableAvailable ? '' : 'is-history'}>{callableAvailable ? '当前可用' : '历史现场'}</i> : null}<time title={new Date(event.timestamp).toLocaleString()}>{relativeTime(event.timestamp, selectedTrace?.startedAt)}</time>{event.durationMs !== undefined ? <small>{event.durationMs.toFixed(1)} ms</small> : null}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>;
|
</div>;
|
||||||
@@ -535,7 +610,7 @@ export function RecordingWorkspace({
|
|||||||
|
|
||||||
<aside className="recording-inspector">
|
<aside className="recording-inspector">
|
||||||
{!selectedEvent ? <div className="recording-column-empty">选择一个 Pipeline 步骤</div> : <>
|
{!selectedEvent ? <div className="recording-column-empty">选择一个 Pipeline 步骤</div> : <>
|
||||||
<header><div><span>{KIND_LABELS[selectedEvent.kind]}</span><strong>{eventTitle(selectedEvent)}</strong><small title={selectedEvent.url || selectedEvent.scriptUrl}>{selectedEvent.url || selectedEvent.scriptUrl || '页面主世界'}</small></div>{selectedEvent.error ? <i className="is-error">ERROR</i> : <i>#{selectedEvent.sequence}</i>}</header>
|
<header><div><span>{selectedEventDirection === 'request' ? '↑ 请求' : selectedEventDirection === 'response' ? '↓ 响应' : KIND_LABELS[selectedEvent.kind]}</span><strong>{eventTitle(selectedEvent)}</strong><small title={selectedEvent.url || selectedEvent.scriptUrl}>{selectedEvent.url || selectedEvent.scriptUrl || '页面主世界'}</small></div>{selectedEvent.error ? <i className="is-error">ERROR</i> : <i>#{selectedEvent.sequence}</i>}</header>
|
||||||
{selectedEvent.kind === 'navigation' && selectedEvent.navigation
|
{selectedEvent.kind === 'navigation' && selectedEvent.navigation
|
||||||
? <dl className="recording-navigation-detail">
|
? <dl className="recording-navigation-detail">
|
||||||
<div><dt>状态</dt><dd>{navigationPhaseLabel(selectedEvent)}</dd></div>
|
<div><dt>状态</dt><dd>{navigationPhaseLabel(selectedEvent)}</dd></div>
|
||||||
@@ -545,10 +620,15 @@ export function RecordingWorkspace({
|
|||||||
</dl>
|
</dl>
|
||||||
: <dl><div><dt>输入</dt><dd>{selectedEvent.byteLength === undefined ? `${selectedEvent.inputs.length} 个值` : `${selectedEvent.byteLength} B`}</dd></div><div><dt>输出</dt><dd>{selectedEvent.resultByteLength === undefined ? `${selectedEvent.outputs.length} 个值` : `${selectedEvent.resultByteLength} B`}</dd></div><div><dt>上游</dt><dd>{incomingLinks.length}</dd></div><div><dt>下游</dt><dd>{outgoingLinks.length}</dd></div></dl>}
|
: <dl><div><dt>输入</dt><dd>{selectedEvent.byteLength === undefined ? `${selectedEvent.inputs.length} 个值` : `${selectedEvent.byteLength} B`}</dd></div><div><dt>输出</dt><dd>{selectedEvent.resultByteLength === undefined ? `${selectedEvent.outputs.length} 个值` : `${selectedEvent.resultByteLength} B`}</dd></div><div><dt>上游</dt><dd>{incomingLinks.length}</dd></div><div><dt>下游</dt><dd>{outgoingLinks.length}</dd></div></dl>}
|
||||||
|
|
||||||
|
{relatedCandidate && relatedSourceEvent && <section className="recording-related-transform">
|
||||||
|
<div><Link2 size={15} /><span><strong>已关联{relatedCandidate.direction === 'request' ? '请求' : '响应'}转换</strong><small>#{relatedSourceEvent.sequence} · {eventTitle(relatedSourceEvent)}</small></span></div>
|
||||||
|
<Button variant="ghost" onClick={() => setSelectedEventId(relatedSourceEvent.id)}>查看{relatedCandidate.direction === 'request' ? '请求' : '响应'}转换</Button>
|
||||||
|
</section>}
|
||||||
|
|
||||||
{selectedCandidate && <section className={`profile-inference is-${selectedCandidate.confidence.level}`}>
|
{selectedCandidate && <section className={`profile-inference is-${selectedCandidate.confidence.level}`}>
|
||||||
<div className="profile-inference__heading">
|
<div className="profile-inference__heading">
|
||||||
<span className="profile-inference__mark"><Sparkles size={15} /></span>
|
<span className="profile-inference__mark"><Sparkles size={15} /></span>
|
||||||
<span><small>自动推断 Profile</small><strong>{selectedCandidate.summary}</strong></span>
|
<span><small>自动识别 · {selectedCandidate.direction === 'request' ? '请求转换' : '响应转换'}</small><strong>{selectedCandidate.summary}</strong></span>
|
||||||
<i><ShieldCheck size={12} />{confidenceLabel(selectedCandidate)}</i>
|
<i><ShieldCheck size={12} />{confidenceLabel(selectedCandidate)}</i>
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-inference__flow" aria-label="推断的数据流">
|
<div className="profile-inference__flow" aria-label="推断的数据流">
|
||||||
@@ -556,6 +636,11 @@ export function RecordingWorkspace({
|
|||||||
<code>{item}</code>{index < selectedCandidate.flow.length - 1 ? <ChevronRight size={12} /> : null}
|
<code>{item}</code>{index < selectedCandidate.flow.length - 1 ? <ChevronRight size={12} /> : null}
|
||||||
</span>)}
|
</span>)}
|
||||||
</div>
|
</div>
|
||||||
|
<dl className="profile-inference__arguments">
|
||||||
|
<div><dt>请求转换</dt><dd>{candidateStatusLabel(selectedCandidate.direction === 'request' ? selectedCandidate : pairedCandidate?.direction === 'request' ? pairedCandidate : undefined)}</dd></div>
|
||||||
|
<div><dt>响应转换</dt><dd>{candidateStatusLabel(selectedCandidate.direction === 'response' ? selectedCandidate : pairedCandidate?.direction === 'response' ? pairedCandidate : undefined)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{pendingGatewayBinding && <div className="profile-inference__next is-ready"><span>{pendingGatewayBinding.candidate.direction === 'request' ? '请求转换' : '响应转换'}已经捕获,正在完成配对方向;完成后会保存为一个双向网关。</span></div>}
|
||||||
{selectedCandidate.sources.length > 1 && <div className="profile-inference__sources">
|
{selectedCandidate.sources.length > 1 && <div className="profile-inference__sources">
|
||||||
{selectedCandidate.sources.map((source, index) => <div key={source.eventId}>
|
{selectedCandidate.sources.map((source, index) => <div key={source.eventId}>
|
||||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||||
@@ -573,27 +658,28 @@ export function RecordingWorkspace({
|
|||||||
<summary>{selectedCandidate.evidence.length} 项证据</summary>
|
<summary>{selectedCandidate.evidence.length} 项证据</summary>
|
||||||
<ol>{selectedCandidate.evidence.map((item) => <li key={item.id} data-strength={item.strength}><i />{item.label}</li>)}</ol>
|
<ol>{selectedCandidate.evidence.map((item) => <li key={item.id} data-strength={item.strength}><i />{item.label}</li>)}</ol>
|
||||||
</details>
|
</details>
|
||||||
{selectedCandidate.missing[0] && <div className="profile-inference__next"><span>{selectedCandidate.missing[0].label}</span>
|
{gatewayNextStep && <div className={`profile-inference__next ${gatewayNextStep.kind === 'create' ? 'is-ready' : ''}`}><span>{candidateAvailable ? gatewayNextStep.description : '关联证据仍然保留;该页面函数属于另一个文档,返回对应页面现场后可以继续。'}</span>
|
||||||
{selectedCandidate.missing[0].action === 'capture-business-function' && DEEP_CAPTURE_AVAILABLE
|
{gatewayNextStep.kind === 'capture' && DEEP_CAPTURE_AVAILABLE
|
||||||
? <Button variant="primary" onClick={() => continueInference(selectedCandidate)}><Sparkles size={14} />{selectedCandidate.direction === 'response' ? '自动捕获完整解密流程' : '自动捕获完整加密流程'}</Button>
|
? <Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => continueInference(gatewayNextStep.candidate)}><Sparkles size={14} />{candidateAvailable ? gatewayNextStep.label : '等待对应页面'}</Button>
|
||||||
|
: gatewayNextStep.kind === 'create'
|
||||||
|
? <Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => void createSuggestedGateway(selectedCandidate)}><FileKey2 size={14} />{candidateAvailable ? gatewayNextStep.label : '等待对应页面'}</Button>
|
||||||
: null}
|
: null}
|
||||||
</div>}
|
</div>}
|
||||||
{selectedCandidate.status === 'ready' && <div className="profile-inference__next is-ready"><span>{candidateAvailable ? (selectedCandidate.direction === 'response' ? '线上响应字段与页面解密调用已经精确关联,可直接生成响应明文网关。' : '页面调用与线上字段已经精确关联,只需确认明文来源和输出形态。') : '关联证据仍然保留;该页面函数属于另一个文档,返回对应页面现场后可以继续生成。'}</span><Button variant="primary" disabled={busy || !candidateAvailable} onClick={() => void createSuggestedGateway(selectedCandidate)}><FileKey2 size={14} />{candidateAvailable ? '生成明文网关' : '等待对应页面'}</Button></div>}
|
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{(selectedEvent.inputPreview || selectedEvent.outputPreview) && <div className="recording-values"><strong>短时样本</strong>{selectedEvent.inputPreview && <pre>{selectedEvent.inputPreview}</pre>}{selectedEvent.outputPreview && <pre>{selectedEvent.outputPreview}</pre>}</div>}
|
{(selectedEvent.inputPreview || selectedEvent.outputPreview) && <div className="recording-values"><strong>短时样本</strong>{selectedEvent.inputPreview && <pre>{selectedEvent.inputPreview}</pre>}{selectedEvent.outputPreview && <pre>{selectedEvent.outputPreview}</pre>}</div>}
|
||||||
{selectedEvent.kind !== 'navigation' ? <details className="recording-evidence"><summary>调用证据</summary><pre>{selectedEvent.stack || selectedEvent.scriptUrl || '没有可用调用栈'}</pre></details> : null}
|
{selectedEvent.kind !== 'navigation' ? <details className="recording-evidence"><summary>调用证据</summary><pre>{selectedEvent.stack || selectedEvent.scriptUrl || '没有可用调用栈'}</pre></details> : null}
|
||||||
|
|
||||||
{canDeepCapture && !selectedCandidate && <section className="recording-deep-action">
|
{canDeepCapture && !selectedCandidate && !relatedCandidate && <section className="recording-deep-action">
|
||||||
<div><Bug size={15} /><span><strong>捕获真实业务上下文</strong><small>{selectedEvent.kind === 'crypto'
|
<div><Bug size={15} /><span><strong>捕获真实业务上下文</strong><small>{selectedEvent.kind === 'crypto'
|
||||||
? '下次命中当前加密调用时暂停'
|
? '下次命中当前加密调用时暂停'
|
||||||
: selectedEvent.kind === 'worker' || selectedEvent.kind === 'message' || selectedEvent.kind === 'beacon'
|
: selectedEvent.kind === 'worker' || selectedEvent.kind === 'message' || selectedEvent.kind === 'beacon'
|
||||||
? '下次命中当前页面通信边界时暂停'
|
? '下次命中当前页面通信边界时暂停'
|
||||||
: '下次发出当前请求时暂停'}</small></span></div>
|
: '下次发出当前请求时暂停'}</small></span></div>
|
||||||
<Button variant="primary" onClick={() => setWorkspaceMode('deep')}><Bug size={14} />深入当前调用</Button>
|
<Button variant="primary" onClick={() => { setCaptureCandidate(undefined); setWorkspaceMode('deep'); }}><Bug size={14} />深入当前调用</Button>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{selectedEvent.callableCapable && selectedEvent.callHandleId && <section className="recording-recipe-action">
|
{selectedEvent.callableCapable && selectedEvent.callHandleId && !selectedCandidate && <section className="recording-recipe-action">
|
||||||
<div><KeyRound size={15} /><span><strong>保存为页面函数</strong><small>{!selectedEventAvailable ? '该调用属于另一个页面文档,返回对应页面后可以恢复' : active ? '保存前会先停止录制,避免轮询继续改变调用现场' : '保留原函数、receiver 与固定参数,页面刷新后失效'}</small></span></div>
|
<div><KeyRound size={15} /><span><strong>保存为页面函数</strong><small>{!selectedEventAvailable ? '该调用属于另一个页面文档,返回对应页面后可以恢复' : active ? '保存前会先停止录制,避免轮询继续改变调用现场' : '保留原函数、receiver 与固定参数,页面刷新后失效'}</small></span></div>
|
||||||
{!callableEditorOpen ? <Button variant="primary" disabled={busy || !selectedEventAvailable} onClick={prepareCallableEditor}><Save size={14} />{active ? '停止录制并保存' : '保存页面函数'}</Button> : <div className="recording-recipe-editor">
|
{!callableEditorOpen ? <Button variant="primary" disabled={busy || !selectedEventAvailable} onClick={prepareCallableEditor}><Save size={14} />{active ? '停止录制并保存' : '保存页面函数'}</Button> : <div className="recording-recipe-editor">
|
||||||
<label><span>名称</span><input value={callableName} onChange={(event) => setCallableName(event.target.value)} /></label>
|
<label><span>名称</span><input value={callableName} onChange={(event) => setCallableName(event.target.value)} /></label>
|
||||||
@@ -614,7 +700,7 @@ export function RecordingWorkspace({
|
|||||||
<DeepCaptureWorkspace
|
<DeepCaptureWorkspace
|
||||||
tab={tab}
|
tab={tab}
|
||||||
selectedEvent={selectedEvent}
|
selectedEvent={selectedEvent}
|
||||||
selectedCandidate={selectedCandidate}
|
selectedCandidate={captureCandidate || selectedCandidate}
|
||||||
autoArmRequest={autoArmRequest}
|
autoArmRequest={autoArmRequest}
|
||||||
recoveryProfileId={recoveryProfileId}
|
recoveryProfileId={recoveryProfileId}
|
||||||
autoRecoveryRequest={autoRecoveryRequest}
|
autoRecoveryRequest={autoRecoveryRequest}
|
||||||
@@ -633,7 +719,7 @@ export function RecordingWorkspace({
|
|||||||
run={run}
|
run={run}
|
||||||
gatewayShared={gatewayShared}
|
gatewayShared={gatewayShared}
|
||||||
onShareGateway={onShareGateway}
|
onShareGateway={onShareGateway}
|
||||||
onOpenCapture={() => { setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
|
onOpenCapture={() => { setCaptureCandidate(undefined); setRecoveryProfileId(''); setWorkspaceMode(DEEP_CAPTURE_AVAILABLE ? 'deep' : 'recording'); }}
|
||||||
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
|
onOpenRecovery={DEEP_CAPTURE_AVAILABLE ? openRecovery : () => setWorkspaceMode('recording')}
|
||||||
deepCaptureAvailable={DEEP_CAPTURE_AVAILABLE}
|
deepCaptureAvailable={DEEP_CAPTURE_AVAILABLE}
|
||||||
recoveryRevision={recoveryRevision}
|
recoveryRevision={recoveryRevision}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BrowserRecordingEventKind,
|
BrowserRecordingEventKind,
|
||||||
BrowserRecordingValueEvidence,
|
BrowserRecordingValueEvidence,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
|
import { readRequestBody } from '@/shared/request-body';
|
||||||
|
|
||||||
type NetworkKind = Extract<BrowserRecordingEventKind, 'fetch' | 'xhr' | 'form' | 'websocket'>;
|
type NetworkKind = Extract<BrowserRecordingEventKind, 'fetch' | 'xhr' | 'form' | 'websocket'>;
|
||||||
|
|
||||||
@@ -333,8 +334,9 @@ export function createNetworkBoundaryRuntime(
|
|||||||
const request = RequestConstructor && input instanceof RequestConstructor ? input : undefined;
|
const request = RequestConstructor && input instanceof RequestConstructor ? input : undefined;
|
||||||
const url = absoluteRequestUrl(request || input);
|
const url = absoluteRequestUrl(request || input);
|
||||||
const method = (init?.method || request?.method || 'GET').toUpperCase().slice(0, 32);
|
const method = (init?.method || request?.method || 'GET').toUpperCase().slice(0, 32);
|
||||||
bestEffort(() => {
|
let stack: ReturnType<NetworkBoundaryHost['stackInfo']> = {};
|
||||||
const body = init?.body;
|
bestEffort(() => { stack = host.stackInfo(); });
|
||||||
|
const emitRequest = (body: unknown) => bestEffort(() => {
|
||||||
host.emit({
|
host.emit({
|
||||||
kind: 'fetch',
|
kind: 'fetch',
|
||||||
operation: 'request',
|
operation: 'request',
|
||||||
@@ -350,9 +352,15 @@ export function createNetworkBoundaryRuntime(
|
|||||||
...headerEvidence(init?.headers || request?.headers, '$headers'),
|
...headerEvidence(init?.headers || request?.headers, '$headers'),
|
||||||
...queryEvidence(request || input),
|
...queryEvidence(request || input),
|
||||||
],
|
],
|
||||||
...host.stackInfo(),
|
...stack,
|
||||||
}, context);
|
}, context);
|
||||||
});
|
});
|
||||||
|
if (request && init?.body === undefined) {
|
||||||
|
void readRequestBody(request, MAX_ASYNC_BINARY_BYTES).then(
|
||||||
|
(body) => emitRequest(body.value),
|
||||||
|
() => emitRequest(undefined),
|
||||||
|
);
|
||||||
|
} else emitRequest(init?.body);
|
||||||
let result: ReturnType<typeof scope.fetch>;
|
let result: ReturnType<typeof scope.fetch>;
|
||||||
try {
|
try {
|
||||||
result = Reflect.apply(original, this, [input, init]);
|
result = Reflect.apply(original, this, [input, init]);
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ function environment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('recording trace runtime', () => {
|
describe('recording trace runtime', () => {
|
||||||
|
it('inherits unique value provenance across a later interaction without guessing shared values', () => {
|
||||||
|
const { runtime, setMaxEntries } = environment();
|
||||||
|
setMaxEntries(20);
|
||||||
|
const value = { path: '$body', fingerprint: 'ciphertext', encoding: 'text' as const, byteLength: 32 };
|
||||||
|
const key = { ...value, fingerprint: 'shared-key' };
|
||||||
|
runtime.bindContext({ traceId: 'B' });
|
||||||
|
runtime.record({ kind: 'fetch', operation: 'response', outputs: [value, key] }, { traceId: 'A' });
|
||||||
|
runtime.record({ kind: 'fetch', operation: 'response', outputs: [key] }, { traceId: 'C' });
|
||||||
|
expect(runtime.record({ kind: 'crypto', operation: 'decrypt', inputs: [value, key] })?.traceId).toBe('A');
|
||||||
|
runtime.record({ kind: 'fetch', operation: 'response', outputs: [value] }, { traceId: 'C' });
|
||||||
|
expect(runtime.record({ kind: 'crypto', operation: 'decrypt', inputs: [value] })?.traceId).toBe('B');
|
||||||
|
});
|
||||||
it('does not create events or trace state while recording is inactive', () => {
|
it('does not create events or trace state while recording is inactive', () => {
|
||||||
const environmentState = environment();
|
const environmentState = environment();
|
||||||
environmentState.setActive(false);
|
environmentState.setActive(false);
|
||||||
|
|||||||
@@ -91,7 +91,19 @@ export function createRecordingTraceRuntime(
|
|||||||
): BrowserRecordingEvent | undefined => {
|
): BrowserRecordingEvent | undefined => {
|
||||||
const recordingId = host.recordingId();
|
const recordingId = host.recordingId();
|
||||||
if (!host.active() || !recordingId) return undefined;
|
if (!host.active() || !recordingId) return undefined;
|
||||||
const eventContext = explicitContext || context();
|
// Values carry causality across await and intervening user interactions.
|
||||||
|
// Only a unique recorded origin can override the current interaction.
|
||||||
|
const fingerprints = new Set((input.inputs || [])
|
||||||
|
.filter((value) => value.byteLength >= 8).map((value) => value.fingerprint));
|
||||||
|
let origins: Map<string, RecordingTraceContext> | undefined;
|
||||||
|
for (const fingerprint of fingerprints) {
|
||||||
|
const matches = new Map(events.filter((event) => event.outputs.some((value) => value.fingerprint === fingerprint))
|
||||||
|
.map((event) => [event.traceId, { traceId: event.traceId, interactionId: event.interactionId }]));
|
||||||
|
if (!matches.size) continue;
|
||||||
|
origins = origins ? new Map([...origins].filter(([traceId]) => matches.has(traceId))) : matches;
|
||||||
|
}
|
||||||
|
const inherited = origins?.size === 1 ? origins.values().next().value : undefined;
|
||||||
|
const eventContext = explicitContext || inherited || context();
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
const item: BrowserRecordingEvent = {
|
const item: BrowserRecordingEvent = {
|
||||||
id: host.unique('event'),
|
id: host.unique('event'),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
|
||||||
|
import { browserGatewayNextStep, recordingEventDirection } from './presentation';
|
||||||
|
|
||||||
|
const event = (id: string, kind: BrowserRecordingEvent['kind'], operation: string): BrowserRecordingEvent => ({
|
||||||
|
id, kind, operation, sequence: 1, timestamp: 1, recordingId: 'recording-1', traceId: 'trace-1',
|
||||||
|
inputs: [], outputs: [], sensitiveCaptured: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidate = (direction: 'request' | 'response', status: BrowserProfileInferenceCandidate['status']): BrowserProfileInferenceCandidate => ({
|
||||||
|
id: `candidate-${direction}`, recordingId: 'recording-1', traceId: 'trace-1',
|
||||||
|
target: { tabId: 1, frameId: 0 }, direction,
|
||||||
|
request: { eventId: `${direction}-boundary`, method: 'POST', url: 'https://example.test/login', bodyFormat: 'json', mappings: [] },
|
||||||
|
source: { eventId: `${direction}-crypto`, kind: 'crypto', operation: direction === 'request' ? 'AES.encrypt' : 'AES.decrypt', arguments: [] },
|
||||||
|
sources: [], status, confidence: { score: 100, level: 'high' }, summary: '', flow: [], pipeline: [], evidence: [{
|
||||||
|
id: `${direction}-evidence`, kind: direction === 'request' ? 'request-boundary' : 'response-boundary', strength: 'proven',
|
||||||
|
label: '', eventIds: [`${direction}-boundary`, `${direction}-crypto`, `${direction}-transform`],
|
||||||
|
}], missing: status === 'capture-required' ? [{ kind: 'business-callable', label: 'capture', action: 'capture-business-function' }] : [],
|
||||||
|
aiContext: {
|
||||||
|
valuePolicy: 'metadata-only',
|
||||||
|
request: { eventId: `${direction}-boundary`, method: 'POST', url: 'https://example.test/login' },
|
||||||
|
source: { eventId: `${direction}-crypto`, kind: 'crypto', operation: direction === 'request' ? 'AES.encrypt' : 'AES.decrypt', arguments: [] },
|
||||||
|
sources: [], evidenceIds: [], requiredDecision: status === 'ready' ? 'none' : 'capture-business-callable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recording presentation', () => {
|
||||||
|
it('labels network boundaries and linked transform events by direction', () => {
|
||||||
|
const request = candidate('request', 'ready');
|
||||||
|
const response = candidate('response', 'ready');
|
||||||
|
|
||||||
|
expect(recordingEventDirection(event('request-boundary', 'fetch', 'request'), [request, response])).toBe('request');
|
||||||
|
expect(recordingEventDirection(event('response-boundary', 'fetch', 'response'), [request, response])).toBe('response');
|
||||||
|
expect(recordingEventDirection(event('response-transform', 'transform', 'Hex.parse'), [request, response])).toBe('response');
|
||||||
|
expect(recordingEventDirection(event('unrelated', 'transform', 'JSON.stringify'), [request, response])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the next incomplete direction instead of claiming the gateway is finished', () => {
|
||||||
|
const request = candidate('request', 'capture-required');
|
||||||
|
const response = candidate('response', 'ready');
|
||||||
|
|
||||||
|
expect(browserGatewayNextStep(response, request)).toMatchObject({
|
||||||
|
kind: 'capture', candidate: request, label: '继续捕获请求方向',
|
||||||
|
});
|
||||||
|
expect(browserGatewayNextStep(response, { ...request, status: 'ready' })).toMatchObject({
|
||||||
|
kind: 'create', label: '生成双向协议网关',
|
||||||
|
});
|
||||||
|
expect(browserGatewayNextStep(response)).toMatchObject({
|
||||||
|
kind: 'create', label: '生成仅响应网关',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
|
||||||
|
|
||||||
|
export type RecordingEventDirection = 'request' | 'response';
|
||||||
|
|
||||||
|
const HTTP_EVENT_KINDS = new Set<BrowserRecordingEvent['kind']>(['fetch', 'xhr', 'form', 'beacon']);
|
||||||
|
|
||||||
|
export function recordingEventDirection(
|
||||||
|
event: BrowserRecordingEvent,
|
||||||
|
candidates: BrowserProfileInferenceCandidate[],
|
||||||
|
): RecordingEventDirection | undefined {
|
||||||
|
if (HTTP_EVENT_KINDS.has(event.kind)) {
|
||||||
|
return event.operation === 'response' || event.operation.startsWith('response.')
|
||||||
|
? 'response'
|
||||||
|
: 'request';
|
||||||
|
}
|
||||||
|
const directions = new Set(candidates.filter((candidate) => (
|
||||||
|
candidate.source.eventId === event.id
|
||||||
|
|| candidate.sources.some((source) => source.eventId === event.id)
|
||||||
|
|| candidate.evidence.some((evidence) => evidence.eventIds.includes(event.id))
|
||||||
|
)).map((candidate) => candidate.direction));
|
||||||
|
return directions.size === 1 ? [...directions][0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrowserGatewayNextStep = {
|
||||||
|
kind: 'capture' | 'create' | 'blocked';
|
||||||
|
candidate: BrowserProfileInferenceCandidate;
|
||||||
|
label?: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const directionName = (direction: BrowserProfileInferenceCandidate['direction']) => (
|
||||||
|
direction === 'request' ? '请求' : '响应'
|
||||||
|
);
|
||||||
|
|
||||||
|
export function browserGatewayNextStep(
|
||||||
|
candidate: BrowserProfileInferenceCandidate,
|
||||||
|
paired?: BrowserProfileInferenceCandidate,
|
||||||
|
): BrowserGatewayNextStep {
|
||||||
|
if (candidate.status !== 'ready') {
|
||||||
|
return candidate.status === 'capture-required'
|
||||||
|
? {
|
||||||
|
kind: 'capture', candidate, label: `继续捕获${directionName(candidate.direction)}方向`,
|
||||||
|
description: `需要先捕获完整的${directionName(candidate.direction)}转换。`,
|
||||||
|
}
|
||||||
|
: { kind: 'blocked', candidate, description: candidate.missing[0]?.label || '当前转换证据还不完整。' };
|
||||||
|
}
|
||||||
|
if (!paired) return {
|
||||||
|
kind: 'create', candidate, label: `生成仅${directionName(candidate.direction)}网关`,
|
||||||
|
description: `当前操作只检测到${directionName(candidate.direction)}转换,将生成单向协议网关。`,
|
||||||
|
};
|
||||||
|
if (paired.status === 'capture-required') return {
|
||||||
|
kind: 'capture', candidate: paired, label: `继续捕获${directionName(paired.direction)}方向`,
|
||||||
|
description: `${directionName(candidate.direction)}方向已就绪,还需要捕获${directionName(paired.direction)}方向。`,
|
||||||
|
};
|
||||||
|
if (paired.status !== 'ready') return {
|
||||||
|
kind: 'blocked', candidate: paired,
|
||||||
|
description: paired.missing[0]?.label || `${directionName(paired.direction)}转换证据还不完整。`,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: 'create', candidate, label: '生成双向协议网关',
|
||||||
|
description: '请求和响应转换都已就绪,将合并为一个双向协议网关。',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1010,7 +1010,7 @@ export async function stopBrowserRecording(target: BrowserTarget, allowSensitive
|
|||||||
|
|
||||||
export async function createRecordedPageCallable(
|
export async function createRecordedPageCallable(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
input: { callHandleId: string; name: string },
|
input: { callHandleId: string; name: string; dynamicInputPaths?: string[] },
|
||||||
): Promise<BrowserPageCallable> {
|
): Promise<BrowserPageCallable> {
|
||||||
const raw = await executeCommand(target, 'callable.create', input);
|
const raw = await executeCommand(target, 'callable.create', input);
|
||||||
const callable = normalizeCallable(raw, target);
|
const callable = normalizeCallable(raw, target);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ interface BrowserTransformWorkspaceProps {
|
|||||||
export interface BrowserTransformSuggestionSeed {
|
export interface BrowserTransformSuggestionSeed {
|
||||||
revision: number;
|
revision: number;
|
||||||
candidate: BrowserProfileInferenceCandidate;
|
candidate: BrowserProfileInferenceCandidate;
|
||||||
callable: BrowserPageCallable;
|
callables: BrowserPageCallable[];
|
||||||
profile: BrowserTransformProfile;
|
profile: BrowserTransformProfile;
|
||||||
sampleBody?: string;
|
sampleBody?: string;
|
||||||
sampleLabel?: string;
|
sampleLabel?: string;
|
||||||
@@ -232,6 +232,11 @@ function callableKindLabel(callable: BrowserPageCallable): string {
|
|||||||
return '全局函数';
|
return '全局函数';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileDirectionLabel(profile: Pick<BrowserTransformProfileInput, 'request' | 'response'>): string {
|
||||||
|
if (profile.request.enabled && profile.response.enabled) return '双向协议网关';
|
||||||
|
return profile.request.enabled ? '仅请求转换' : '仅响应转换';
|
||||||
|
}
|
||||||
|
|
||||||
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
|
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
|
||||||
if (node.kind === 'builtin') return node.inputs;
|
if (node.kind === 'builtin') return node.inputs;
|
||||||
if (node.kind === 'page.call') return node.arguments;
|
if (node.kind === 'page.call') return node.arguments;
|
||||||
@@ -516,8 +521,8 @@ export function BrowserTransformWorkspace({
|
|||||||
update: (current) => ({
|
update: (current) => ({
|
||||||
...current,
|
...current,
|
||||||
callables: [
|
callables: [
|
||||||
...current.callables.filter((item) => item.id !== suggestion.callable.id),
|
...current.callables.filter((item) => !suggestion.callables.some((callable) => callable.id === item.id)),
|
||||||
suggestion.callable,
|
...suggestion.callables,
|
||||||
],
|
],
|
||||||
profiles: [
|
profiles: [
|
||||||
suggestion.profile,
|
suggestion.profile,
|
||||||
@@ -801,7 +806,7 @@ export function BrowserTransformWorkspace({
|
|||||||
<div>
|
<div>
|
||||||
<small>Agent 已完成本地验证 · {pendingValidation.proofLevel === 'exact' ? '报文一致' : pendingValidation.proofLevel === 'structure' ? '结构一致' : '执行通过'}</small>
|
<small>Agent 已完成本地验证 · {pendingValidation.proofLevel === 'exact' ? '报文一致' : pendingValidation.proofLevel === 'structure' ? '结构一致' : '执行通过'}</small>
|
||||||
<strong>{pendingValidation.profile.name}</strong>
|
<strong>{pendingValidation.profile.name}</strong>
|
||||||
<p>{pendingValidation.profile.origin} · {pendingValidation.profile.request.enabled ? '请求加密' : '响应解密'} · {Math.max(1, Math.ceil((pendingValidation.expiresAt - Date.now()) / 60_000))} 分钟后过期</p>
|
<p>{pendingValidation.profile.origin} · {profileDirectionLabel(pendingValidation.profile)} · {Math.max(1, Math.ceil((pendingValidation.expiresAt - Date.now()) / 60_000))} 分钟后过期</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="transform-validation-pending__actions">
|
<div className="transform-validation-pending__actions">
|
||||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void resolvePendingValidation('discard')}>放弃</Button>
|
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void resolvePendingValidation('discard')}>放弃</Button>
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export function TransformProfileRail({
|
|||||||
<header><div><strong>明文网关</strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={onCreate}><Plus size={15} /></Button></header>
|
<header><div><strong>明文网关</strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={onCreate}><Plus size={15} /></Button></header>
|
||||||
<div className="transform-profile-list">
|
<div className="transform-profile-list">
|
||||||
{profiles.map((profile) => {
|
{profiles.map((profile) => {
|
||||||
|
const directionLabel = profile.request.enabled && profile.response.enabled
|
||||||
|
? '双向'
|
||||||
|
: profile.request.enabled ? '仅请求' : '仅响应';
|
||||||
const ready = (!profile.recovery || profile.recovery.state === 'ready')
|
const ready = (!profile.recovery || profile.recovery.state === 'ready')
|
||||||
&& originOf(tab?.url) === profile.origin && [profile.request, profile.response]
|
&& originOf(tab?.url) === profile.origin && [profile.request, profile.response]
|
||||||
.flatMap((item) => item.enabled ? item.nodes : [])
|
.flatMap((item) => item.enabled ? item.nodes : [])
|
||||||
@@ -61,7 +64,7 @@ export function TransformProfileRail({
|
|||||||
.every((node) => callableIds.has(node.callableId));
|
.every((node) => callableIds.has(node.callableId));
|
||||||
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => onSelect(profile)}>
|
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => onSelect(profile)}>
|
||||||
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
|
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
|
||||||
<span><strong>{profile.name}</strong><small>{profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
|
<span><strong>{profile.name}</strong><small>{directionLabel} · {profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
|
||||||
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
|
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
|
||||||
</button>;
|
</button>;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -76,6 +76,38 @@ describe('guided browser transform compiler', () => {
|
|||||||
}).inputPaths).toEqual(['body', 'body.options']);
|
}).inputPaths).toEqual(['body', 'body.options']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes CryptoJS decrypt WordArray hex before writing the plaintext body', async () => {
|
||||||
|
const decryptCallable: BrowserPageCallable = {
|
||||||
|
...callable,
|
||||||
|
id: 'decrypt-aes',
|
||||||
|
operation: 'cryptojs.AES.decrypt',
|
||||||
|
crypto: {
|
||||||
|
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric',
|
||||||
|
operation: 'AES.decrypt', algorithm: 'AES.decrypt', outputEncoding: 'hex',
|
||||||
|
},
|
||||||
|
output: { dataType: 'Object', encoding: 'hex', shape: 'value', paths: [] },
|
||||||
|
};
|
||||||
|
const direction = compileGuidedTransform(defaultGuidedTransform(decryptCallable), decryptCallable);
|
||||||
|
const result = await executeTransformDirection('profile-decrypt', 'response', direction, {
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://example.test/login',
|
||||||
|
headers: [],
|
||||||
|
bodyBase64: bodyBase64('cipher'),
|
||||||
|
}, async (callableId) => ({
|
||||||
|
callableId,
|
||||||
|
type: 'object',
|
||||||
|
preview: '7b226f6b223a747275657d',
|
||||||
|
value: '7b226f6b223a747275657d',
|
||||||
|
durationMs: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(decodeBody(result.bodyBase64)).toBe('{"ok":true}');
|
||||||
|
expect(parseGuidedTransform(direction, [decryptCallable])).toMatchObject({
|
||||||
|
callableId: decryptCallable.id,
|
||||||
|
outputKind: 'body',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('compiles a form field and its content type without exposing DAG details', async () => {
|
it('compiles a form field and its content type without exposing DAG details', async () => {
|
||||||
const guide = {
|
const guide = {
|
||||||
...defaultGuidedTransform(callable, { outputKind: 'form-field', outputField: 'encryptedData' }),
|
...defaultGuidedTransform(callable, { outputKind: 'form-field', outputField: 'encryptedData' }),
|
||||||
|
|||||||
@@ -106,8 +106,21 @@ export function compileGuidedTransform(guide: GuidedTransformDraft, callable?: B
|
|||||||
callableId: guide.callableId,
|
callableId: guide.callableId,
|
||||||
arguments: inputNodes.map((node) => ({ nodeId: node.id })),
|
arguments: inputNodes.map((node) => ({ nodeId: node.id })),
|
||||||
};
|
};
|
||||||
const callReference = { nodeId: callId, path: guide.resultPath?.trim() || undefined };
|
|
||||||
const nodes: BrowserTransformPipelineNode[] = [...inputNodes, callNode];
|
const nodes: BrowserTransformPipelineNode[] = [...inputNodes, callNode];
|
||||||
|
let callReference = { nodeId: callId, path: guide.resultPath?.trim() || undefined };
|
||||||
|
if (callable?.crypto?.adapterId === 'cryptojs'
|
||||||
|
&& callable.crypto.operation.toLowerCase().includes('decrypt')
|
||||||
|
&& callable.output.encoding === 'hex') {
|
||||||
|
const decodeId = uid('decode');
|
||||||
|
nodes.push({
|
||||||
|
id: decodeId,
|
||||||
|
name: '还原 CryptoJS 解密字节',
|
||||||
|
kind: 'builtin',
|
||||||
|
operation: 'hex.decode',
|
||||||
|
inputs: [callReference],
|
||||||
|
});
|
||||||
|
callReference = { nodeId: decodeId, path: undefined };
|
||||||
|
}
|
||||||
const bodyFormat = envelopeBodyFormat(callable);
|
const bodyFormat = envelopeBodyFormat(callable);
|
||||||
|
|
||||||
if (bodyFormat) {
|
if (bodyFormat) {
|
||||||
@@ -243,8 +256,14 @@ export function parseGuidedTransform(
|
|||||||
|
|
||||||
if (bodyOutputs.length !== 1) return undefined;
|
if (bodyOutputs.length !== 1) return undefined;
|
||||||
const output = bodyOutputs[0];
|
const output = bodyOutputs[0];
|
||||||
const resultPath = referenceFromCall(output.source.nodeId, output.source.path, call.id);
|
const decode = direction.nodes.find((node): node is Extract<BrowserTransformPipelineNode, { kind: 'builtin' }> => (
|
||||||
if (output.source.nodeId !== call.id) return undefined;
|
node.kind === 'builtin' && node.operation === 'hex.decode'
|
||||||
|
&& node.inputs.length === 1 && node.id === output.source.nodeId
|
||||||
|
));
|
||||||
|
const resultPath = decode
|
||||||
|
? referenceFromCall(decode.inputs[0].nodeId, decode.inputs[0].path, call.id)
|
||||||
|
: referenceFromCall(output.source.nodeId, output.source.path, call.id);
|
||||||
|
if (decode ? decode.inputs[0].nodeId !== call.id : output.source.nodeId !== call.id) return undefined;
|
||||||
if (output.destination === 'body') {
|
if (output.destination === 'body') {
|
||||||
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'body', outputField: '', setFormContentType: false };
|
return { callableId: call.callableId, inputPaths, resultPath, outputKind: 'body', outputField: '', setFormContentType: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
BrowserPageCallable,
|
BrowserPageCallable,
|
||||||
BrowserProfileInferenceCandidate,
|
BrowserProfileInferenceCandidate,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import { createBrowserTransformProfileInput } from './profile-draft';
|
import { createBrowserTransformProfileInput, pairedBrowserTransformCandidate } from './profile-draft';
|
||||||
import { executeTransformDirection } from './mapping';
|
import { executeTransformDirection } from './mapping';
|
||||||
|
|
||||||
const tab: ActiveTabInfo = {
|
const tab: ActiveTabInfo = {
|
||||||
@@ -98,7 +98,106 @@ describe('browser transform profile draft', () => {
|
|||||||
expect.objectContaining({ kind: 'output.write', destination: 'body' }),
|
expect.objectContaining({ kind: 'output.write', destination: 'body' }),
|
||||||
]));
|
]));
|
||||||
expect(profile.match).toEqual({ methods: ['GET'], urlPattern: '*/api/profile' });
|
expect(profile.match).toEqual({ methods: ['GET'], urlPattern: '*/api/profile' });
|
||||||
expect(profile.name).toContain('响应明文网关');
|
expect(profile.name).toContain('浏览器协议网关');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps response ciphertext, key, and iv into one decrypt callable', async () => {
|
||||||
|
const dynamicCallable: BrowserPageCallable = {
|
||||||
|
...callable,
|
||||||
|
inputSlots: [
|
||||||
|
callable.inputSlots[0],
|
||||||
|
{ id: 'key', name: 'key', index: 1, role: 'key', dataType: 'string', required: true, retained: false },
|
||||||
|
{ id: 'iv', name: 'iv', index: 2, role: 'iv', dataType: 'string', required: true, retained: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const dynamicCandidate: BrowserProfileInferenceCandidate = {
|
||||||
|
...responseCandidate,
|
||||||
|
request: {
|
||||||
|
...responseCandidate.request,
|
||||||
|
mappings: [
|
||||||
|
{ sourceEventId: 'decrypt-event', destination: 'body.message', serialization: 'json-field' },
|
||||||
|
{ sourceEventId: 'decrypt-event', destination: 'body.key', serialization: 'json-field' },
|
||||||
|
{ sourceEventId: 'decrypt-event', destination: 'body.iv', serialization: 'json-field' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const profile = createBrowserTransformProfileInput(tab, undefined, dynamicCallable, dynamicCandidate);
|
||||||
|
const packet = {
|
||||||
|
method: 'POST', url: dynamicCandidate.request.url,
|
||||||
|
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||||
|
bodyBase64: btoa(JSON.stringify({ message: 'cipher', key: '0011', iv: 'aabb' })),
|
||||||
|
};
|
||||||
|
let received: unknown[] = [];
|
||||||
|
|
||||||
|
await executeTransformDirection('test', 'response', profile.response, packet, async (callableId, args) => {
|
||||||
|
received = args;
|
||||||
|
return { callableId, type: 'string', preview: 'plain', value: 'plain', durationMs: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(received).toEqual(['cipher', '0011', 'aabb']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs one browser transaction and compiles both directions into one profile', () => {
|
||||||
|
const requestCandidate: BrowserProfileInferenceCandidate = {
|
||||||
|
...responseCandidate,
|
||||||
|
id: 'candidate-request',
|
||||||
|
transactionId: 'fetch-1',
|
||||||
|
direction: 'request',
|
||||||
|
request: { ...responseCandidate.request, eventId: 'request-event', method: 'POST' },
|
||||||
|
};
|
||||||
|
const pairedResponse: BrowserProfileInferenceCandidate = {
|
||||||
|
...responseCandidate,
|
||||||
|
transactionId: 'fetch-1',
|
||||||
|
request: { ...responseCandidate.request, method: 'POST' },
|
||||||
|
};
|
||||||
|
const encryptCallable: BrowserPageCallable = {
|
||||||
|
...callable,
|
||||||
|
id: 'encrypt-callable',
|
||||||
|
name: '页面 AES 加密',
|
||||||
|
operation: 'AES.encrypt',
|
||||||
|
provenance: { eventId: 'encrypt-event' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(pairedBrowserTransformCandidate([requestCandidate, pairedResponse], requestCandidate)?.id)
|
||||||
|
.toBe(pairedResponse.id);
|
||||||
|
const profile = createBrowserTransformProfileInput(
|
||||||
|
tab,
|
||||||
|
undefined,
|
||||||
|
encryptCallable,
|
||||||
|
requestCandidate,
|
||||||
|
undefined,
|
||||||
|
{ candidate: pairedResponse, callable },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(profile.request.enabled).toBe(true);
|
||||||
|
expect(profile.response.enabled).toBe(true);
|
||||||
|
expect(profile.request.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: encryptCallable.id }));
|
||||||
|
expect(profile.response.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: callable.id }));
|
||||||
|
expect(profile.name).toBe('POST */api/profile 浏览器协议网关');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not guess when one trace contains multiple opposite candidates for the same route', () => {
|
||||||
|
const requestCandidate = { ...responseCandidate, id: 'request', direction: 'request' as const };
|
||||||
|
const responseA = { ...responseCandidate, id: 'response-a' };
|
||||||
|
const responseB = { ...responseCandidate, id: 'response-b' };
|
||||||
|
|
||||||
|
expect(pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate)).toBeUndefined();
|
||||||
|
expect(() => pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate, true))
|
||||||
|
.toThrow('尚未保存单向网关');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pair candidates from different recording sessions', () => {
|
||||||
|
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
|
||||||
|
const staleResponse = { ...responseCandidate, id: 'stale-response', transactionId: 'fetch-1', recordingId: 'recording-old' };
|
||||||
|
|
||||||
|
expect(pairedBrowserTransformCandidate([requestCandidate, staleResponse], requestCandidate)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fall back to the route when transaction IDs disagree', () => {
|
||||||
|
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
|
||||||
|
const otherResponse = { ...responseCandidate, id: 'other-response', transactionId: 'fetch-2' };
|
||||||
|
|
||||||
|
expect(pairedBrowserTransformCandidate([requestCandidate, otherResponse], requestCandidate)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes request-transaction profiles because they mutate one browser session', () => {
|
it('serializes request-transaction profiles because they mutate one browser session', () => {
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ interface RequestRouteSource {
|
|||||||
method?: string;
|
method?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrowserTransformProfileBinding {
|
||||||
|
candidate: BrowserProfileInferenceCandidate;
|
||||||
|
callable: BrowserPageCallable;
|
||||||
|
packet?: BrowserTransformPacket;
|
||||||
|
}
|
||||||
|
|
||||||
function originOf(url?: string): string {
|
function originOf(url?: string): string {
|
||||||
try { return url ? new URL(url).origin : ''; } catch { return ''; }
|
try { return url ? new URL(url).origin : ''; } catch { return ''; }
|
||||||
}
|
}
|
||||||
@@ -28,6 +34,32 @@ function emptyDirection(enabled = false): BrowserTransformDirection {
|
|||||||
return { enabled, nodes: [] };
|
return { enabled, nodes: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameTarget(left: BrowserProfileInferenceCandidate, right: BrowserProfileInferenceCandidate): boolean {
|
||||||
|
return left.target.tabId === right.target.tabId
|
||||||
|
&& left.target.frameId === right.target.frameId
|
||||||
|
&& (!left.target.documentId || !right.target.documentId || left.target.documentId === right.target.documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pairedBrowserTransformCandidate(
|
||||||
|
candidates: BrowserProfileInferenceCandidate[],
|
||||||
|
candidate: BrowserProfileInferenceCandidate,
|
||||||
|
requireUnambiguous = false,
|
||||||
|
): BrowserProfileInferenceCandidate | undefined {
|
||||||
|
const opposite = candidates.filter((item) => item.id !== candidate.id
|
||||||
|
&& item.recordingId === candidate.recordingId
|
||||||
|
&& item.direction !== candidate.direction && sameTarget(item, candidate));
|
||||||
|
const matches = candidate.transactionId
|
||||||
|
? opposite.filter((item) => item.transactionId === candidate.transactionId)
|
||||||
|
: opposite.filter((item) => !item.transactionId
|
||||||
|
&& item.traceId === candidate.traceId
|
||||||
|
&& item.request.method.toUpperCase() === candidate.request.method.toUpperCase()
|
||||||
|
&& item.request.url === candidate.request.url);
|
||||||
|
if (requireUnambiguous && matches.length > 1) {
|
||||||
|
throw new ExtensionError('profile_evidence_ambiguous', `同一事务存在 ${matches.length} 个反方向候选,无法完整合并;尚未保存单向网关`);
|
||||||
|
}
|
||||||
|
return matches.length === 1 ? matches[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): {
|
function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callable?: BrowserPageCallable, packet?: BrowserTransformPacket): {
|
||||||
inputPaths?: string[];
|
inputPaths?: string[];
|
||||||
outputKind?: GuidedTransformOutputKind;
|
outputKind?: GuidedTransformOutputKind;
|
||||||
@@ -37,7 +69,12 @@ function candidateGuidance(candidate?: BrowserProfileInferenceCandidate, callabl
|
|||||||
const serialization = candidate?.request.serialization;
|
const serialization = candidate?.request.serialization;
|
||||||
if (!destination) return {};
|
if (!destination) return {};
|
||||||
if (candidate?.direction === 'response') {
|
if (candidate?.direction === 'response') {
|
||||||
return { inputPaths: [destination], outputKind: 'body' };
|
return {
|
||||||
|
inputPaths: candidate.request.mappings
|
||||||
|
.map((mapping) => mapping.destination)
|
||||||
|
.filter((path): path is string => Boolean(path)),
|
||||||
|
outputKind: 'body',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (serialization === 'form-field') {
|
if (serialization === 'form-field') {
|
||||||
let inputPaths: string[] | undefined;
|
let inputPaths: string[] | undefined;
|
||||||
@@ -68,6 +105,7 @@ export function createBrowserTransformProfileInput(
|
|||||||
callable?: BrowserPageCallable,
|
callable?: BrowserPageCallable,
|
||||||
candidate?: BrowserProfileInferenceCandidate,
|
candidate?: BrowserProfileInferenceCandidate,
|
||||||
packet?: BrowserTransformPacket,
|
packet?: BrowserTransformPacket,
|
||||||
|
paired?: BrowserTransformProfileBinding,
|
||||||
): BrowserTransformProfileInput {
|
): BrowserTransformProfileInput {
|
||||||
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate, callable, packet));
|
const guide = defaultGuidedTransform(callable, candidateGuidance(candidate, callable, packet));
|
||||||
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
|
const compiled = callable ? compileGuidedTransform(guide, callable) : emptyDirection(true);
|
||||||
@@ -76,9 +114,9 @@ export function createBrowserTransformProfileInput(
|
|||||||
url: candidate.request.url,
|
url: candidate.request.url,
|
||||||
method: candidate.request.method,
|
method: candidate.request.method,
|
||||||
} : event;
|
} : event;
|
||||||
return {
|
const profile: BrowserTransformProfileInput = {
|
||||||
name: routeEvent?.url
|
name: routeEvent?.url
|
||||||
? `${routeEvent.method || 'HTTP'} ${routeOf(routeEvent, tab)} ${responseDirection ? '响应' : '请求'}明文网关`
|
? `${routeEvent.method || 'HTTP'} ${routeOf(routeEvent, tab)} 浏览器协议网关`
|
||||||
: `${tab.title || '当前页面'} 明文网关`,
|
: `${tab.title || '当前页面'} 明文网关`,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
target: { tabId: tab.id, frameId: 0 },
|
target: { tabId: tab.id, frameId: 0 },
|
||||||
@@ -89,4 +127,16 @@ export function createBrowserTransformProfileInput(
|
|||||||
failMode: 'closed',
|
failMode: 'closed',
|
||||||
maxConcurrency: callable?.kind === 'request-transaction' ? 1 : 2,
|
maxConcurrency: callable?.kind === 'request-transaction' ? 1 : 2,
|
||||||
};
|
};
|
||||||
|
if (!paired) return profile;
|
||||||
|
if (!candidate
|
||||||
|
|| pairedBrowserTransformCandidate([candidate, paired.candidate], candidate)?.id !== paired.candidate.id) {
|
||||||
|
throw new ExtensionError('profile_evidence_mismatch', '请求与响应候选不属于同一个浏览器协议网关');
|
||||||
|
}
|
||||||
|
const pairedGuide = defaultGuidedTransform(
|
||||||
|
paired.callable,
|
||||||
|
candidateGuidance(paired.candidate, paired.callable, paired.packet),
|
||||||
|
);
|
||||||
|
profile[paired.candidate.direction] = compileGuidedTransform(pairedGuide, paired.callable);
|
||||||
|
if (paired.callable.kind === 'request-transaction') profile.maxConcurrency = 1;
|
||||||
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
BrowserProfileInferenceCandidate, BrowserRecordingEvent,
|
BrowserProfileInferenceCandidate, BrowserRecordingEvent,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import './deep-capture-workspace.css';
|
import './deep-capture-workspace.css';
|
||||||
import { cryptoDeepCaptureMatcher } from '@/features/browser-crypto/model';
|
import { eventMatcher } from './matcher';
|
||||||
import { capturedCallableSample, type CapturedCallableSample } from './callable-sample';
|
import { capturedCallableSample, type CapturedCallableSample } from './callable-sample';
|
||||||
|
|
||||||
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
|
||||||
@@ -43,32 +43,6 @@ const STATUS_LABELS: Record<BrowserDeepCaptureStatus['state'], string> = {
|
|||||||
error: '需要处理',
|
error: '需要处理',
|
||||||
};
|
};
|
||||||
|
|
||||||
function eventMatcher(
|
|
||||||
event?: BrowserRecordingEvent,
|
|
||||||
candidate?: BrowserProfileInferenceCandidate,
|
|
||||||
): BrowserDeepCaptureMatcher | undefined {
|
|
||||||
if (!event) return undefined;
|
|
||||||
const frameHints = candidate?.capturePlan?.matcherEventId === event.id
|
|
||||||
? candidate.capturePlan.frameHints
|
|
||||||
: undefined;
|
|
||||||
const crypto = cryptoDeepCaptureMatcher(event);
|
|
||||||
if (crypto) return { ...crypto, frameHints };
|
|
||||||
if (['fetch', 'xhr', 'form'].includes(event.kind) && event.url) {
|
|
||||||
return { kind: 'request', urlPattern: event.url, frameHints };
|
|
||||||
}
|
|
||||||
if (['beacon', 'worker', 'message'].includes(event.kind) && event.wrapperHandleId) {
|
|
||||||
return {
|
|
||||||
kind: 'boundary',
|
|
||||||
eventKind: event.kind as 'beacon' | 'worker' | 'message',
|
|
||||||
operation: event.operation,
|
|
||||||
wrapperHandleId: event.wrapperHandleId,
|
|
||||||
scriptUrl: event.scriptUrl,
|
|
||||||
frameHints,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactUrl(value: string): string {
|
function compactUrl(value: string): string {
|
||||||
if (!value) return '内联脚本';
|
if (!value) return '内联脚本';
|
||||||
try {
|
try {
|
||||||
@@ -416,8 +390,8 @@ export function DeepCaptureWorkspace({
|
|||||||
}, recoveryProfileId
|
}, recoveryProfileId
|
||||||
? '新页面函数已捕获,旧网关继续停用;请完成本地回放验证'
|
? '新页面函数已捕获,旧网关继续停用;请完成本地回放验证'
|
||||||
: captureStrategy === 'request-transaction'
|
: captureStrategy === 'request-transaction'
|
||||||
? '页面请求事务与明文网关已自动保存,真实发送将在回放时被截获'
|
? '页面请求事务已捕获;存在响应方向时将继续完成同一个协议网关'
|
||||||
: '完整业务加密流程与明文网关已自动保存');
|
: '完整业务转换流程已捕获;存在配对方向时将继续完成同一个协议网关');
|
||||||
}, [
|
}, [
|
||||||
onRecoveryCaptured,
|
onRecoveryCaptured,
|
||||||
onUseRecommendedCallable,
|
onUseRecommendedCallable,
|
||||||
@@ -433,20 +407,23 @@ export function DeepCaptureWorkspace({
|
|||||||
const useRecordedRecommendation = () => run(async () => {
|
const useRecordedRecommendation = () => run(async () => {
|
||||||
if (!target || !recordedRecommendation?.source.callHandleId) throw new Error('推荐调用已经失效');
|
if (!target || !recordedRecommendation?.source.callHandleId) throw new Error('推荐调用已经失效');
|
||||||
setStatus(await request('deep.capture.resume', target));
|
setStatus(await request('deep.capture.resume', target));
|
||||||
let callable = callables.find((item) => item.provenance.eventId === recordedRecommendation.source.eventId);
|
const inputCount = recordedRecommendation.source.dynamicInputPaths?.length || 1;
|
||||||
|
let callable = callables.find((item) => item.provenance.eventId === recordedRecommendation.source.eventId
|
||||||
|
&& item.inputSlots.filter((slot) => !slot.retained).length === inputCount);
|
||||||
if (!callable) {
|
if (!callable) {
|
||||||
callable = await request('callable.create', {
|
callable = await request('callable.create', {
|
||||||
...target,
|
...target,
|
||||||
source: 'recording',
|
source: 'recording',
|
||||||
callHandleId: recordedRecommendation.source.callHandleId,
|
callHandleId: recordedRecommendation.source.callHandleId,
|
||||||
name: `${recordedRecommendation.source.crypto?.algorithm || recordedRecommendation.source.crypto?.operation || recordedRecommendation.source.operation} 页面函数`,
|
name: `${recordedRecommendation.source.crypto?.algorithm || recordedRecommendation.source.crypto?.operation || recordedRecommendation.source.operation} 页面函数`,
|
||||||
|
dynamicInputPaths: recordedRecommendation.source.dynamicInputPaths,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const selected = callable;
|
const selected = callable;
|
||||||
setCallables((current) => [...current.filter((item) => item.id !== selected.id), selected]);
|
setCallables((current) => [...current.filter((item) => item.id !== selected.id), selected]);
|
||||||
setSelectedCallableId(selected.id);
|
setSelectedCallableId(selected.id);
|
||||||
await onUseRecommendedCallable?.(recordedRecommendation, selected);
|
await onUseRecommendedCallable?.(recordedRecommendation, selected);
|
||||||
}, '已使用录制调用生成并保存明文网关');
|
}, '当前转换方向已完成;存在配对方向时将继续合并');
|
||||||
|
|
||||||
const executeCallable = () => run(async () => {
|
const executeCallable = () => run(async () => {
|
||||||
if (!target || !selectedCallableId) throw new Error('请选择页面函数');
|
if (!target || !selectedCallableId) throw new Error('请选择页面函数');
|
||||||
@@ -605,7 +582,7 @@ export function DeepCaptureWorkspace({
|
|||||||
<header><Braces size={14} /><strong>函数评估</strong></header>
|
<header><Braces size={14} /><strong>函数评估</strong></header>
|
||||||
<div className="deep-frame-summary"><strong>{selectedFrame?.functionName || '未选择调用帧'}</strong><small>{selectedFrame ? `${compactUrl(selectedFrame.url)}:${selectedFrame.lineNumber}:${selectedFrame.columnNumber}` : ''}</small>{selectedFrame?.sourceMapUrl && <small title={selectedFrame.sourceMapUrl}>Source Map 元数据 · {compactUrl(selectedFrame.sourceMapUrl)}</small>}<span>{selectedFrame?.thisPreview || ''}</span></div>
|
<div className="deep-frame-summary"><strong>{selectedFrame?.functionName || '未选择调用帧'}</strong><small>{selectedFrame ? `${compactUrl(selectedFrame.url)}:${selectedFrame.lineNumber}:${selectedFrame.columnNumber}` : ''}</small>{selectedFrame?.sourceMapUrl && <small title={selectedFrame.sourceMapUrl}>Source Map 元数据 · {compactUrl(selectedFrame.sourceMapUrl)}</small>}<span>{selectedFrame?.thisPreview || ''}</span></div>
|
||||||
{selectedFrame?.sourceKind === 'extension-hook' ? <div className="deep-function-assessment is-hook"><Bug size={15} /><span><strong>这是插件注入的观测帧</strong><small>它只负责记录或设置断点,不是页面业务代码。请选择调用栈中标记为“页面函数”的下游帧。</small></span></div> : selectedFrame?.functionInspection?.resolved ? <div className={`deep-function-assessment ${selectedFrame.functionInspection.riskFlags.length ? 'has-risk' : 'is-clean'}`}>
|
{selectedFrame?.sourceKind === 'extension-hook' ? <div className="deep-function-assessment is-hook"><Bug size={15} /><span><strong>这是插件注入的观测帧</strong><small>它只负责记录或设置断点,不是页面业务代码。请选择调用栈中标记为“页面函数”的下游帧。</small></span></div> : selectedFrame?.functionInspection?.resolved ? <div className={`deep-function-assessment ${selectedFrame.functionInspection.riskFlags.length ? 'has-risk' : 'is-clean'}`}>
|
||||||
{selectedFrame.functionInspection.riskFlags.length ? <><ShieldAlert size={15} /><span><strong>已阻止注册为可回放函数</strong><small>{selectedFrame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join(' · ')}。直接调用可能改变页面或发送真实请求。</small></span></> : <><Check size={15} /><span><strong>函数对象已自动解析</strong><small>{selectedFrame.functionInspection.parameterCount || 0} 个参数 · {selectedFrame.functionInspection.resolution === 'receiver-method' ? '页面方法' : selectedFrame.functionInspection.resolution === 'scope-binding' ? '闭包绑定' : '当前栈帧'} · 未发现明显副作用</small></span></>}
|
{selectedFrame.functionInspection.riskFlags.length ? <><ShieldAlert size={15} /><span><strong>已阻止注册为可回放函数</strong><small>{selectedFrame.functionInspection.riskFlags.map((risk) => RISK_LABELS[risk]).join(' · ')}。直接调用可能改变页面或发送真实请求。</small></span></> : <><Check size={15} /><span><strong>函数对象已自动解析</strong><small>{selectedFrame.functionInspection.parameterCount || 0} 个参数 · {selectedFrame.functionInspection.resolution === 'receiver-method' ? '页面方法' : selectedFrame.functionInspection.resolution === 'scope-binding' ? '闭包绑定' : selectedFrame.functionInspection.resolution === 'current-function' ? '当前函数' : '当前栈帧'} · 未发现明显副作用</small></span></>}
|
||||||
</div> : <div className="deep-function-assessment has-risk"><AlertTriangle size={15} /><span><strong>无法唯一解析当前函数</strong><small>{selectedFrame?.functionInspection?.candidateCount ? `发现 ${selectedFrame.functionInspection.candidateCount} 个同分候选;` : ''}请选择其他业务栈帧,或在高级模式中指定闭包变量。</small></span></div>}
|
</div> : <div className="deep-function-assessment has-risk"><AlertTriangle size={15} /><span><strong>无法唯一解析当前函数</strong><small>{selectedFrame?.functionInspection?.candidateCount ? `发现 ${selectedFrame.functionInspection.candidateCount} 个同分候选;` : ''}请选择其他业务栈帧,或在高级模式中指定闭包变量。</small></span></div>}
|
||||||
<div className="deep-adapter-editor__primary">{recoveryProfileId
|
<div className="deep-adapter-editor__primary">{recoveryProfileId
|
||||||
? <Button variant="primary" disabled={busy || !recoverySelectionReady} onClick={() => void captureRecovery(recoveryCaptureStrategy)}><RotateCcw size={14} />{recoveryCaptureStrategy === 'request-transaction' ? '按所选函数恢复请求事务' : '用所选函数恢复绑定'}</Button>
|
? <Button variant="primary" disabled={busy || !recoverySelectionReady} onClick={() => void captureRecovery(recoveryCaptureStrategy)}><RotateCcw size={14} />{recoveryCaptureStrategy === 'request-transaction' ? '按所选函数恢复请求事务' : '用所选函数恢复绑定'}</Button>
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ function comparableUrl(value?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nameMatches(frameName: string, hintName: string): boolean {
|
function nameMatches(frameName: string, hintName: string): boolean {
|
||||||
return frameName === hintName || frameName.endsWith(`.${hintName}`) || hintName.endsWith(`.${frameName}`);
|
const normalize = (value: string) => /^(?:\(anonymous\)|<anonymous>|anonymous)$/i.test(value.trim())
|
||||||
|
? '(anonymous)'
|
||||||
|
: value;
|
||||||
|
const frame = normalize(frameName);
|
||||||
|
const hint = normalize(hintName);
|
||||||
|
return frame === hint || frame.endsWith(`.${hint}`) || hint.endsWith(`.${frame}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFrameHint[]): BrowserBusinessFrameHint | undefined {
|
function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFrameHint[]): BrowserBusinessFrameHint | undefined {
|
||||||
@@ -30,10 +35,12 @@ function matchingHint(frame: BrowserDeepCaptureFrame, hints: BrowserBusinessFram
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEventHandler(frame: BrowserDeepCaptureFrame): boolean {
|
function isEventHandler(frame: BrowserDeepCaptureFrame): boolean {
|
||||||
|
if (frame.functionInspection?.resolution === 'event-listener') return true;
|
||||||
if (EVENT_HANDLER_NAME.test(frame.functionName)) return true;
|
if (EVENT_HANDLER_NAME.test(frame.functionName)) return true;
|
||||||
const parameters = frame.functionInspection?.parameterNames || [];
|
const parameters = frame.functionInspection?.parameterNames || [];
|
||||||
return parameters.some((name) => /^(?:event|evt)$/i.test(name))
|
return parameters.some((name) => /^(?:e|event|evt)$/i.test(name))
|
||||||
&& /(?:Element|Document|Window)/.test(frame.thisPreview);
|
&& (/(?:Element|Document|Window|#[A-Za-z_$][\w$-]*)/.test(frame.thisPreview)
|
||||||
|
|| frame.scopes.some((scope) => scope.variables.some((variable) => /Event\b/.test(variable.preview))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hintedFrameOrder(
|
function hintedFrameOrder(
|
||||||
@@ -121,9 +128,13 @@ export function rankBusinessFrames(
|
|||||||
.filter((frame) => frame.sourceKind === 'page' && frame.functionInspection?.resolved && matchingHint(frame, hints))
|
.filter((frame) => frame.sourceKind === 'page' && frame.functionInspection?.resolved && matchingHint(frame, hints))
|
||||||
.sort((left, right) => hintedFrameOrder(left, right, hints));
|
.sort((left, right) => hintedFrameOrder(left, right, hints));
|
||||||
const closestHinted = resolvedHinted[0];
|
const closestHinted = resolvedHinted[0];
|
||||||
const closestRisks = closestHinted?.functionInspection?.riskFlags || [];
|
const unhintedEventHandler = hints.length ? undefined : ranked.find((frame) => (
|
||||||
const transactionRequired = Boolean(closestHinted
|
frame.sourceKind === 'page' && frame.functionInspection?.resolved && isEventHandler(frame)
|
||||||
&& (isEventHandler(closestHinted) || closestRisks.some((risk) => TRANSACTION_RISKS.has(risk))));
|
));
|
||||||
|
const transactionFrame = closestHinted || unhintedEventHandler;
|
||||||
|
const closestRisks = transactionFrame?.functionInspection?.riskFlags || [];
|
||||||
|
const transactionRequired = Boolean(transactionFrame
|
||||||
|
&& (isEventHandler(transactionFrame) || closestRisks.some((risk) => TRANSACTION_RISKS.has(risk))));
|
||||||
const transactionBlocked = Boolean(transactionRequired && closestRisks.includes('storage'));
|
const transactionBlocked = Boolean(transactionRequired && closestRisks.includes('storage'));
|
||||||
const eligible = ordered.filter((frame) => frame.functionInspection?.resolved
|
const eligible = ordered.filter((frame) => frame.functionInspection?.resolved
|
||||||
&& !frame.functionInspection.riskFlags.length && !isEventHandler(frame));
|
&& !frame.functionInspection.riskFlags.length && !isEventHandler(frame));
|
||||||
@@ -133,19 +144,19 @@ export function rankBusinessFrames(
|
|||||||
const automatic = automaticEligible[0];
|
const automatic = automaticEligible[0];
|
||||||
const alternative = automaticEligible[1];
|
const alternative = automaticEligible[1];
|
||||||
let automaticCapture: RankedBusinessFrames['automaticCapture'];
|
let automaticCapture: RankedBusinessFrames['automaticCapture'];
|
||||||
if (transactionRequired && !transactionBlocked && closestHinted) {
|
if (transactionRequired && !transactionBlocked && transactionFrame) {
|
||||||
automaticCapture = {
|
automaticCapture = {
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
strategy: 'request-transaction',
|
strategy: 'request-transaction',
|
||||||
frameId: closestHinted.id,
|
frameId: transactionFrame.id,
|
||||||
reason: isEventHandler(closestHinted)
|
reason: isEventHandler(transactionFrame)
|
||||||
? '共同业务入口是页面事件处理器,将在隔离事务中截获并取消真实请求'
|
? '共同业务入口是页面事件处理器,将在隔离事务中截获并取消真实请求'
|
||||||
: '共同业务函数直接读取页面或发送请求,将以隔离事务保留完整动态参数关系',
|
: '共同业务函数直接读取页面或发送请求,将以隔离事务保留完整动态参数关系',
|
||||||
};
|
};
|
||||||
} else if (transactionBlocked && closestHinted) {
|
} else if (transactionBlocked && transactionFrame) {
|
||||||
automaticCapture = {
|
automaticCapture = {
|
||||||
state: 'blocked',
|
state: 'blocked',
|
||||||
frameId: closestHinted.id,
|
frameId: transactionFrame.id,
|
||||||
reason: '共同业务函数会访问页面存储;当前事务回滚无法证明存储副作用已完全隔离',
|
reason: '共同业务函数会访问页面存储;当前事务回滚无法证明存储副作用已完全隔离',
|
||||||
};
|
};
|
||||||
} else if (automatic && alternative && (automatic.businessScore || 0) - (alternative.businessScore || 0) < 8) {
|
} else if (automatic && alternative && (automatic.businessScore || 0) - (alternative.businessScore || 0) < 8) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { BrowserDeepCaptureMatcher, BrowserProfileInferenceCandidate, BrowserRecordingEvent } from '@/types/models';
|
||||||
|
import { cryptoDeepCaptureMatcher } from '@/features/browser-crypto/model';
|
||||||
|
|
||||||
|
export function eventMatcher(event?: BrowserRecordingEvent, candidate?: BrowserProfileInferenceCandidate): BrowserDeepCaptureMatcher | undefined {
|
||||||
|
if (!event) return undefined;
|
||||||
|
const frameHints = candidate?.capturePlan?.frameHints;
|
||||||
|
const crypto = cryptoDeepCaptureMatcher(event);
|
||||||
|
if (crypto) return { ...crypto, frameHints };
|
||||||
|
if (['fetch', 'xhr', 'form'].includes(event.kind) && event.url) return { kind: 'request', urlPattern: event.url, frameHints };
|
||||||
|
if (['beacon', 'worker', 'message'].includes(event.kind) && event.wrapperHandleId) return {
|
||||||
|
kind: 'boundary', eventKind: event.kind as 'beacon' | 'worker' | 'message', operation: event.operation,
|
||||||
|
wrapperHandleId: event.wrapperHandleId, scriptUrl: event.scriptUrl, frameHints,
|
||||||
|
};
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const attachedTabs = new Set<number>();
|
|||||||
const knownTabs = new Set<number>();
|
const knownTabs = new Set<number>();
|
||||||
let failResume = false;
|
let failResume = false;
|
||||||
let failDetach = false;
|
let failDetach = false;
|
||||||
|
let commandHandler: ((method: string, params?: Record<string, unknown>) => unknown) | undefined;
|
||||||
|
|
||||||
const STORAGE_KEY = 'session.deep-capture.v1';
|
const STORAGE_KEY = 'session.deep-capture.v1';
|
||||||
|
|
||||||
@@ -27,9 +28,9 @@ const debuggerApi = {
|
|||||||
getTargets: vi.fn(async () => [...knownTabs].map((tabId) => ({
|
getTargets: vi.fn(async () => [...knownTabs].map((tabId) => ({
|
||||||
attached: attachedTabs.has(tabId), tabId, id: `target-${tabId}`, type: 'page', url: 'https://example.test/',
|
attached: attachedTabs.has(tabId), tabId, id: `target-${tabId}`, type: 'page', url: 'https://example.test/',
|
||||||
}))),
|
}))),
|
||||||
sendCommand: vi.fn(async (_target: { tabId?: number; sessionId?: string }, method: string) => {
|
sendCommand: vi.fn(async (_target: { tabId?: number; sessionId?: string }, method: string, params?: Record<string, unknown>) => {
|
||||||
if (method === 'Debugger.resume' && failResume) throw new Error('fixture resume failed');
|
if (method === 'Debugger.resume' && failResume) throw new Error('fixture resume failed');
|
||||||
return {};
|
return commandHandler?.(method, params) || {};
|
||||||
}),
|
}),
|
||||||
onEvent: { addListener: vi.fn((listener: typeof eventListeners[number]) => eventListeners.push(listener)) },
|
onEvent: { addListener: vi.fn((listener: typeof eventListeners[number]) => eventListeners.push(listener)) },
|
||||||
onDetach: { addListener: vi.fn((listener: (source: { tabId?: number; sessionId?: string }, reason: string) => void) => detachListeners.push(listener)) },
|
onDetach: { addListener: vi.fn((listener: (source: { tabId?: number; sessionId?: string }, reason: string) => void) => detachListeners.push(listener)) },
|
||||||
@@ -125,6 +126,7 @@ describe('deep capture debugger lifecycle', () => {
|
|||||||
knownTabs.clear();
|
knownTabs.clear();
|
||||||
failResume = false;
|
failResume = false;
|
||||||
failDetach = false;
|
failDetach = false;
|
||||||
|
commandHandler = undefined;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +164,17 @@ describe('deep capture debugger lifecycle', () => {
|
|||||||
expect(debuggerApi.attach).not.toHaveBeenCalled();
|
expect(debuggerApi.attach).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows Agent capture after local release but never takes over an active local session', async () => {
|
||||||
|
const target = { tabId: 39, frameId: 0 };
|
||||||
|
const matcher = { kind: 'request' as const, urlPattern: '/login' };
|
||||||
|
const owner = { kind: 'grant' as const, grantId: 'agent', expiresAt: Date.now() + 60_000 };
|
||||||
|
await startDeepCapture(target, matcher);
|
||||||
|
await expect(startDeepCapture(target, matcher, owner)).rejects.toMatchObject({ code: 'permission_denied' });
|
||||||
|
await resumeDeepCapture(target);
|
||||||
|
await expect(startDeepCapture(target, matcher, owner)).resolves.toMatchObject({ state: 'armed' });
|
||||||
|
await resumeDeepCapture(target, 'agent-done', owner);
|
||||||
|
});
|
||||||
|
|
||||||
it('restores a paused page when callable capture validation fails', async () => {
|
it('restores a paused page when callable capture validation fails', async () => {
|
||||||
const target = { tabId: 20, frameId: 0 };
|
const target = { tabId: 20, frameId: 0 };
|
||||||
seedStatus(target.tabId, pausedStatus(target.tabId, {
|
seedStatus(target.tabId, pausedStatus(target.tabId, {
|
||||||
@@ -322,6 +335,67 @@ describe('deep capture debugger lifecycle', () => {
|
|||||||
await resumeDeepCapture(target);
|
await resumeDeepCapture(target);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves an anonymous form handler through its paused SubmitEvent', async () => {
|
||||||
|
const target = { tabId: 30, frameId: 0 };
|
||||||
|
commandHandler = (method, params) => {
|
||||||
|
if (method === 'Runtime.getProperties' && params?.objectId === 'local-scope') {
|
||||||
|
return { result: [{ name: 'e', value: { type: 'object', description: 'SubmitEvent' } }] };
|
||||||
|
}
|
||||||
|
if (method === 'Runtime.getProperties' && params?.objectId === 'listener-1') {
|
||||||
|
return { internalProperties: [{
|
||||||
|
name: '[[FunctionLocation]]',
|
||||||
|
value: { type: 'object', value: { scriptId: 'page-script', lineNumber: 160 } },
|
||||||
|
}] };
|
||||||
|
}
|
||||||
|
if (method === 'Debugger.evaluateOnCallFrame') {
|
||||||
|
const expression = String(params?.expression || '');
|
||||||
|
return expression.includes('arguments.callee')
|
||||||
|
? { result: { type: 'function', objectId: 'listener-1' } }
|
||||||
|
: { result: { type: 'undefined' } };
|
||||||
|
}
|
||||||
|
if (method === 'Runtime.callFunctionOn' && params?.objectId === 'listener-1') {
|
||||||
|
return { result: { value: {
|
||||||
|
functionName: '', parameterCount: 1, parameterNames: ['e'], riskFlags: ['network', 'dom'],
|
||||||
|
} } };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
await startDeepCapture(target, {
|
||||||
|
kind: 'request',
|
||||||
|
urlPattern: '/crypto/sqli/aes-ecb/encrypt/login',
|
||||||
|
frameHints: [{
|
||||||
|
functionName: '<anonymous>', url: 'https://example.test/login', support: 1, averageDepth: 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
for (const listener of eventListeners) listener({ tabId: target.tabId }, 'Debugger.paused', {
|
||||||
|
reason: 'XHR',
|
||||||
|
callFrames: [{
|
||||||
|
callFrameId: 'hook-frame', functionName: 'recordedFetch',
|
||||||
|
url: 'chrome-extension://fixture/page-recorder-main-world.js',
|
||||||
|
location: { scriptId: 'hook-script', lineNumber: 10, columnNumber: 1 },
|
||||||
|
scopeChain: [], this: { type: 'object', description: 'Window' },
|
||||||
|
}, {
|
||||||
|
callFrameId: 'page-frame', functionName: '', url: 'https://example.test/login',
|
||||||
|
location: { scriptId: 'page-script', lineNumber: 162, columnNumber: 38 },
|
||||||
|
scopeChain: [{ type: 'local', object: { type: 'object', objectId: 'local-scope' } }],
|
||||||
|
this: { type: 'object', description: 'HTMLFormElement' },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(async () => expect((await deepCaptureStatus(target)).pause?.collecting).toBe(false));
|
||||||
|
const result = await deepCaptureStatus(target);
|
||||||
|
expect(result.pause?.frames[1]?.functionInspection).toMatchObject({
|
||||||
|
resolved: true, resolution: 'current-function', referenceExpression: 'arguments.callee',
|
||||||
|
});
|
||||||
|
expect(result.pause?.automaticCapture).toMatchObject({
|
||||||
|
state: 'ready', strategy: 'request-transaction', frameId: 'page-frame',
|
||||||
|
});
|
||||||
|
expect(debuggerApi.sendCommand).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(), 'Debugger.getFunctionLocation', expect.anything(),
|
||||||
|
);
|
||||||
|
await resumeDeepCapture(target);
|
||||||
|
});
|
||||||
|
|
||||||
it('observes same-origin service-worker targets without routing their pauses into the page capture', async () => {
|
it('observes same-origin service-worker targets without routing their pauses into the page capture', async () => {
|
||||||
const target = { tabId: 29, frameId: 0 };
|
const target = { tabId: 29, frameId: 0 };
|
||||||
await startDeepCapture(target, { kind: 'request', urlPattern: '/login' });
|
await startDeepCapture(target, { kind: 'request', urlPattern: '/login' });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { normalizeCallable } from '@/features/page-callable/service';
|
|||||||
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
|
import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants';
|
||||||
import { rankBusinessFrames } from './business-frame-ranker';
|
import { rankBusinessFrames } from './business-frame-ranker';
|
||||||
import { getTab } from '@/platform/browser/targets';
|
import { getTab } from '@/platform/browser/targets';
|
||||||
|
import { serializeTabExecution, withPageNetworkGuard } from '@/features/page-callable/network-guard';
|
||||||
|
|
||||||
interface Debuggee {
|
interface Debuggee {
|
||||||
tabId?: number;
|
tabId?: number;
|
||||||
@@ -58,6 +59,7 @@ interface InspectedFunctionCandidate {
|
|||||||
riskFlags: NonNullable<BrowserDeepCaptureFrame['functionInspection']>['riskFlags'];
|
riskFlags: NonNullable<BrowserDeepCaptureFrame['functionInspection']>['riskFlags'];
|
||||||
scriptId?: string;
|
scriptId?: string;
|
||||||
lineNumber?: number;
|
lineNumber?: number;
|
||||||
|
columnNumber?: number;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ interface CDPCallFrame {
|
|||||||
callFrameId?: string;
|
callFrameId?: string;
|
||||||
functionName?: string;
|
functionName?: string;
|
||||||
location?: { scriptId?: string; lineNumber?: number; columnNumber?: number };
|
location?: { scriptId?: string; lineNumber?: number; columnNumber?: number };
|
||||||
|
functionLocation?: { scriptId: string; lineNumber: number; columnNumber: number };
|
||||||
url?: string;
|
url?: string;
|
||||||
scopeChain?: CDPScope[];
|
scopeChain?: CDPScope[];
|
||||||
this?: CDPRemoteObject;
|
this?: CDPRemoteObject;
|
||||||
@@ -114,6 +117,7 @@ const MAX_VALUE_PREVIEW = 512;
|
|||||||
const MAX_VARIABLE_DETAIL = 4_096;
|
const MAX_VARIABLE_DETAIL = 4_096;
|
||||||
const MAX_SCOPE_DETAIL_BUDGET = 16_384;
|
const MAX_SCOPE_DETAIL_BUDGET = 16_384;
|
||||||
const MAX_FUNCTION_CANDIDATES = 24;
|
const MAX_FUNCTION_CANDIDATES = 24;
|
||||||
|
const RETAINED_FUNCTIONS_KEY = '__YAKIT_DEEP_FUNCTIONS__';
|
||||||
const MAX_WORKER_TARGETS = 16;
|
const MAX_WORKER_TARGETS = 16;
|
||||||
const MAX_WORKER_SCRIPT_COUNT = 256;
|
const MAX_WORKER_SCRIPT_COUNT = 256;
|
||||||
const acceptedScopeTypes = new Set<BrowserDeepCaptureScope['type']>([
|
const acceptedScopeTypes = new Set<BrowserDeepCaptureScope['type']>([
|
||||||
@@ -288,6 +292,8 @@ function debuggerTarget(tabId: number): Debuggee {
|
|||||||
|
|
||||||
function assertSessionOwner(status: StoredDeepCaptureStatus, owner?: DeepCaptureOwner): void {
|
function assertSessionOwner(status: StoredDeepCaptureStatus, owner?: DeepCaptureOwner): void {
|
||||||
if (!owner || owner.kind === 'local') return;
|
if (!owner || owner.kind === 'local') return;
|
||||||
|
// Released local/Agent history is not a live debugger lock.
|
||||||
|
if (!status.pause && status.recovery?.page === 'running' && status.recovery.debugger === 'detached') return;
|
||||||
if (status.owner.kind !== 'grant' || status.owner.grantId !== owner.grantId) {
|
if (status.owner.kind !== 'grant' || status.owner.grantId !== owner.grantId) {
|
||||||
throw new ExtensionError('permission_denied', '该页面的深度捕获由另一个会话控制');
|
throw new ExtensionError('permission_denied', '该页面的深度捕获由另一个会话控制');
|
||||||
}
|
}
|
||||||
@@ -616,7 +622,7 @@ async function inspectFunctionExpression(
|
|||||||
if (evaluated?.exceptionDetails || evaluated?.result?.type !== 'function' || !evaluated.result.objectId) return undefined;
|
if (evaluated?.exceptionDetails || evaluated?.result?.type !== 'function' || !evaluated.result.objectId) return undefined;
|
||||||
const objectId = evaluated.result.objectId;
|
const objectId = evaluated.result.objectId;
|
||||||
try {
|
try {
|
||||||
const [metadata, location] = await Promise.all([
|
const [metadata, properties] = await Promise.all([
|
||||||
sendCommand<{ result?: CDPRemoteObject }>(target, 'Runtime.callFunctionOn', {
|
sendCommand<{ result?: CDPRemoteObject }>(target, 'Runtime.callFunctionOn', {
|
||||||
objectId,
|
objectId,
|
||||||
functionDeclaration: `function () {
|
functionDeclaration: `function () {
|
||||||
@@ -640,8 +646,11 @@ async function inspectFunctionExpression(
|
|||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
silent: true,
|
silent: true,
|
||||||
}).catch(() => undefined),
|
}).catch(() => undefined),
|
||||||
sendCommand<{ location?: { scriptId?: string; lineNumber?: number } }>(target, 'Debugger.getFunctionLocation', {
|
sendCommand<{ internalProperties?: Array<{ name?: string; value?: CDPRemoteObject }> }>(target, 'Runtime.getProperties', {
|
||||||
functionId: objectId,
|
objectId,
|
||||||
|
ownProperties: false,
|
||||||
|
accessorPropertiesOnly: false,
|
||||||
|
generatePreview: false,
|
||||||
}).catch(() => undefined),
|
}).catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
const value = metadata?.result?.value;
|
const value = metadata?.result?.value;
|
||||||
@@ -652,14 +661,19 @@ async function inspectFunctionExpression(
|
|||||||
if (Array.isArray(input.riskFlags)) {
|
if (Array.isArray(input.riskFlags)) {
|
||||||
riskFlags.push(...input.riskFlags.filter((item): item is typeof riskFlags[number] => typeof item === 'string' && allowed.has(item)));
|
riskFlags.push(...input.riskFlags.filter((item): item is typeof riskFlags[number] => typeof item === 'string' && allowed.has(item)));
|
||||||
}
|
}
|
||||||
|
const rawLocation = properties?.internalProperties
|
||||||
|
?.find((property) => property.name === '[[FunctionLocation]]')?.value?.value;
|
||||||
|
const location = rawLocation && typeof rawLocation === 'object'
|
||||||
|
? rawLocation as { scriptId?: string; lineNumber?: number; columnNumber?: number }
|
||||||
|
: undefined;
|
||||||
const functionName = typeof input.functionName === 'string' ? input.functionName.slice(0, 240) : '';
|
const functionName = typeof input.functionName === 'string' ? input.functionName.slice(0, 240) : '';
|
||||||
const sameScript = Boolean(location?.location?.scriptId && location.location.scriptId === frame.scriptId);
|
const sameScript = Boolean(location?.scriptId && location.scriptId === frame.scriptId);
|
||||||
const nameMatch = functionName === frame.functionName || expression === frame.functionName;
|
const nameMatch = functionName === frame.functionName || expression === frame.functionName;
|
||||||
let score = resolution === 'frame-name' ? 36 : resolution === 'receiver-method' ? 30 : 12;
|
let score = resolution === 'current-function' ? 48 : resolution === 'frame-name' ? 36 : resolution === 'receiver-method' ? 30 : 12;
|
||||||
if (sameScript) score += 42;
|
if (sameScript) score += 42;
|
||||||
if (nameMatch) score += 28;
|
if (nameMatch) score += 28;
|
||||||
if (sameScript && Number.isFinite(location?.location?.lineNumber)) {
|
if (sameScript && Number.isFinite(location?.lineNumber)) {
|
||||||
const distance = Math.max(0, frame.lineNumber - (Number(location?.location?.lineNumber) + 1));
|
const distance = Math.max(0, frame.lineNumber - (Number(location?.lineNumber) + 1));
|
||||||
score += Math.max(0, 12 - Math.min(12, Math.floor(distance / 20)));
|
score += Math.max(0, 12 - Math.min(12, Math.floor(distance / 20)));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -671,8 +685,9 @@ async function inspectFunctionExpression(
|
|||||||
? input.parameterNames.filter((item): item is string => typeof item === 'string' && validIdentifier(item)).slice(0, 16)
|
? input.parameterNames.filter((item): item is string => typeof item === 'string' && validIdentifier(item)).slice(0, 16)
|
||||||
: [],
|
: [],
|
||||||
riskFlags,
|
riskFlags,
|
||||||
scriptId: location?.location?.scriptId,
|
scriptId: location?.scriptId,
|
||||||
lineNumber: Number.isFinite(location?.location?.lineNumber) ? Number(location?.location?.lineNumber) + 1 : undefined,
|
lineNumber: Number.isFinite(location?.lineNumber) ? Number(location?.lineNumber) + 1 : undefined,
|
||||||
|
columnNumber: Number.isFinite(location?.columnNumber) ? Number(location?.columnNumber) + 1 : undefined,
|
||||||
score,
|
score,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -689,8 +704,10 @@ async function inspectFrameFunction(
|
|||||||
expressions.push({ expression: frame.functionName, resolution: 'frame-name' });
|
expressions.push({ expression: frame.functionName, resolution: 'frame-name' });
|
||||||
expressions.push({ expression: receiverFunctionExpression(frame.functionName), resolution: 'receiver-method' });
|
expressions.push({ expression: receiverFunctionExpression(frame.functionName), resolution: 'receiver-method' });
|
||||||
}
|
}
|
||||||
if (!validIdentifier(frame.functionName) || frame.functionName === '(anonymous)') {
|
{
|
||||||
|
expressions.push({ expression: 'arguments.callee', resolution: 'current-function' });
|
||||||
for (const variable of frame.scopes.flatMap((scope) => scope.variables)) {
|
for (const variable of frame.scopes.flatMap((scope) => scope.variables)) {
|
||||||
|
if (expressions.length >= MAX_FUNCTION_CANDIDATES) break;
|
||||||
if (variable.type !== 'function' || !validIdentifier(variable.name)) continue;
|
if (variable.type !== 'function' || !validIdentifier(variable.name)) continue;
|
||||||
expressions.push({ expression: variable.name, resolution: 'scope-binding' });
|
expressions.push({ expression: variable.name, resolution: 'scope-binding' });
|
||||||
if (expressions.length >= MAX_FUNCTION_CANDIDATES) break;
|
if (expressions.length >= MAX_FUNCTION_CANDIDATES) break;
|
||||||
@@ -700,10 +717,19 @@ async function inspectFrameFunction(
|
|||||||
const inspected = (await Promise.all(unique.map((item) => inspectFunctionExpression(
|
const inspected = (await Promise.all(unique.map((item) => inspectFunctionExpression(
|
||||||
target, frame, item.expression, item.resolution,
|
target, frame, item.expression, item.resolution,
|
||||||
)))).filter((item): item is InspectedFunctionCandidate => Boolean(item))
|
)))).filter((item): item is InspectedFunctionCandidate => Boolean(item))
|
||||||
.filter((item) => item.resolution !== 'scope-binding' || item.scriptId === frame.scriptId);
|
.filter((item) => item.scriptId === frame.scriptId
|
||||||
const candidates = [...new Map(inspected
|
&& (!frame.functionLocation || (item.lineNumber === frame.functionLocation.lineNumber + 1
|
||||||
.sort((left, right) => right.score - left.score || left.expression.localeCompare(right.expression))
|
&& item.columnNumber === frame.functionLocation.columnNumber + 1)));
|
||||||
.map((item) => [`${item.functionName}\n${item.scriptId || ''}\n${item.lineNumber || ''}\n${item.parameterCount}`, item])).values()]
|
if (!inspected.length && frame.functionLocation) {
|
||||||
|
const listener = await inspectPausedEventListener(target, frame);
|
||||||
|
if (listener) inspected.push(listener);
|
||||||
|
}
|
||||||
|
const distinct = new Map<string, InspectedFunctionCandidate>();
|
||||||
|
for (const item of inspected.sort((left, right) => right.score - left.score)) {
|
||||||
|
const key = `${item.scriptId}:${item.lineNumber}:${item.columnNumber}`;
|
||||||
|
if (!distinct.has(key)) distinct.set(key, item);
|
||||||
|
}
|
||||||
|
const candidates = [...distinct.values()]
|
||||||
.sort((left, right) => right.score - left.score || left.expression.localeCompare(right.expression));
|
.sort((left, right) => right.score - left.score || left.expression.localeCompare(right.expression));
|
||||||
const selected = candidates[0];
|
const selected = candidates[0];
|
||||||
const ambiguous = Boolean(selected && candidates[1] && selected.score - candidates[1].score < 8);
|
const ambiguous = Boolean(selected && candidates[1] && selected.score - candidates[1].score < 8);
|
||||||
@@ -719,6 +745,54 @@ async function inspectFrameFunction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectPausedEventListener(target: Debuggee, frame: BrowserDeepCaptureFrame): Promise<InspectedFunctionCandidate | undefined> {
|
||||||
|
const matches: CDPRemoteObject[] = [];
|
||||||
|
// Query the browser's listener registry, including delegated and shadow-tree listeners.
|
||||||
|
// Unlike arguments.callee this also works for strict functions and arrow functions.
|
||||||
|
for (const expression of ['document', 'window']) {
|
||||||
|
const evaluated = await sendCommand<{ result?: CDPRemoteObject }>(target, 'Debugger.evaluateOnCallFrame', {
|
||||||
|
callFrameId: frame.id, expression, objectGroup: 'yakit-deep-capture', silent: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
if (!evaluated?.result?.objectId) continue;
|
||||||
|
try {
|
||||||
|
const result = await sendCommand<{ listeners?: Array<{
|
||||||
|
scriptId: string; lineNumber: number; columnNumber: number;
|
||||||
|
handler?: CDPRemoteObject; originalHandler?: CDPRemoteObject;
|
||||||
|
}> }>(target, 'DOMDebugger.getEventListeners', {
|
||||||
|
objectId: evaluated.result.objectId, depth: -1, pierce: true,
|
||||||
|
});
|
||||||
|
for (const listener of result.listeners || []) {
|
||||||
|
if (listener.scriptId !== frame.scriptId
|
||||||
|
|| listener.lineNumber !== frame.functionLocation!.lineNumber
|
||||||
|
|| listener.columnNumber !== frame.functionLocation!.columnNumber) continue;
|
||||||
|
const handler = listener.originalHandler || listener.handler;
|
||||||
|
if (handler?.objectId && handler.type === 'function') matches.push(handler);
|
||||||
|
}
|
||||||
|
} finally { await sendCommand(target, 'Runtime.releaseObject', { objectId: evaluated.result.objectId }).catch(() => undefined); }
|
||||||
|
}
|
||||||
|
if (!matches.length) return undefined;
|
||||||
|
const first = matches[0];
|
||||||
|
try {
|
||||||
|
for (const other of matches.slice(1)) {
|
||||||
|
const equal = await sendCommand<{ result?: CDPRemoteObject }>(target, 'Runtime.callFunctionOn', {
|
||||||
|
objectId: first.objectId, functionDeclaration: 'function(other) { return this === other; }',
|
||||||
|
arguments: [{ objectId: other.objectId }], returnByValue: true,
|
||||||
|
});
|
||||||
|
if (equal.result?.value !== true) return undefined;
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await sendCommand(target, 'Runtime.callFunctionOn', {
|
||||||
|
objectId: first.objectId,
|
||||||
|
functionDeclaration: `function(id) { (globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}] ||= Object.create(null))[id] = this; }`,
|
||||||
|
arguments: [{ value: id }],
|
||||||
|
});
|
||||||
|
return await inspectFunctionExpression(target, frame,
|
||||||
|
`globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}][${JSON.stringify(id)}]`, 'event-listener');
|
||||||
|
} finally {
|
||||||
|
await Promise.all(matches.map((handler) => sendCommand(target, 'Runtime.releaseObject', { objectId: handler.objectId }).catch(() => undefined)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pauseSkeleton(
|
function pauseSkeleton(
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
matcher?: BrowserDeepCaptureMatcher,
|
matcher?: BrowserDeepCaptureMatcher,
|
||||||
@@ -741,6 +815,9 @@ function pauseSkeleton(
|
|||||||
sourceMapUrl: script?.sourceMapUrl,
|
sourceMapUrl: script?.sourceMapUrl,
|
||||||
lineNumber: Math.max(1, Number(frame.location?.lineNumber || 0) + 1),
|
lineNumber: Math.max(1, Number(frame.location?.lineNumber || 0) + 1),
|
||||||
columnNumber: Math.max(1, Number(frame.location?.columnNumber || 0) + 1),
|
columnNumber: Math.max(1, Number(frame.location?.columnNumber || 0) + 1),
|
||||||
|
functionLocation: frame.functionLocation
|
||||||
|
? { lineNumber: frame.functionLocation.lineNumber, columnNumber: frame.functionLocation.columnNumber }
|
||||||
|
: undefined,
|
||||||
scopes: [],
|
scopes: [],
|
||||||
thisPreview: remotePreview(frame.this),
|
thisPreview: remotePreview(frame.this),
|
||||||
sourceKind,
|
sourceKind,
|
||||||
@@ -1352,6 +1429,7 @@ async function capturePageCallableWhilePaused(
|
|||||||
delete globalThis[${JSON.stringify(retainedCallKey)}];
|
delete globalThis[${JSON.stringify(retainedCallKey)}];
|
||||||
if (!retainedCall || !Array.isArray(retainedCall.args)) throw new Error("业务函数的暂停现场已经失效");
|
if (!retainedCall || !Array.isArray(retainedCall.args)) throw new Error("业务函数的暂停现场已经失效");
|
||||||
const candidate = (${functionExpression});
|
const candidate = (${functionExpression});
|
||||||
|
delete globalThis[${JSON.stringify(RETAINED_FUNCTIONS_KEY)}];
|
||||||
if (typeof candidate !== "function") throw new Error("选中的表达式不是函数");
|
if (typeof candidate !== "function") throw new Error("选中的表达式不是函数");
|
||||||
const source = Function.prototype.toString.call(candidate).slice(0, 65536);
|
const source = Function.prototype.toString.call(candidate).slice(0, 65536);
|
||||||
const transaction = ${JSON.stringify(requestTransaction || null)};
|
const transaction = ${JSON.stringify(requestTransaction || null)};
|
||||||
@@ -1368,31 +1446,6 @@ async function capturePageCallableWhilePaused(
|
|||||||
registry = new Map();
|
registry = new Map();
|
||||||
Object.defineProperty(globalThis, key, { value: registry, configurable: true, enumerable: false });
|
Object.defineProperty(globalThis, key, { value: registry, configurable: true, enumerable: false });
|
||||||
}
|
}
|
||||||
if (transaction && typeof globalThis.fetch === "function") {
|
|
||||||
const previousFetch = globalThis.fetch;
|
|
||||||
let restoreTimer;
|
|
||||||
const restoreFetch = () => {
|
|
||||||
if (globalThis.fetch === transactionCaptureFetch) globalThis.fetch = previousFetch;
|
|
||||||
if (restoreTimer) clearTimeout(restoreTimer);
|
|
||||||
};
|
|
||||||
const transactionCaptureFetch = async function(input, init) {
|
|
||||||
let request;
|
|
||||||
try { request = new Request(input, init); } catch { return Reflect.apply(previousFetch, this, [input, init]); }
|
|
||||||
const expectedURL = new URL(transaction.request.url, location.href).toString();
|
|
||||||
if (transaction.request.boundary !== "fetch"
|
|
||||||
|| request.method.toUpperCase() !== transaction.request.method.toUpperCase()
|
|
||||||
|| request.url !== expectedURL) {
|
|
||||||
return Reflect.apply(previousFetch, this, [input, init]);
|
|
||||||
}
|
|
||||||
restoreFetch();
|
|
||||||
return new Response(JSON.stringify({ success: false, error: "request captured before transaction replay" }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
globalThis.fetch = transactionCaptureFetch;
|
|
||||||
restoreTimer = setTimeout(restoreFetch, 10000);
|
|
||||||
}
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
id: ${JSON.stringify(callableId)}, name: ${JSON.stringify(name)}, kind: ${JSON.stringify(callableKind)},
|
id: ${JSON.stringify(callableId)}, name: ${JSON.stringify(name)}, kind: ${JSON.stringify(callableKind)},
|
||||||
operation: candidate.name || ${JSON.stringify(functionExpression)}, origin: location.origin,
|
operation: candidate.name || ${JSON.stringify(functionExpression)}, origin: location.origin,
|
||||||
@@ -1476,6 +1529,20 @@ export async function createCapturedPageCallable(
|
|||||||
callFrameId: string,
|
callFrameId: string,
|
||||||
input: CapturedPageCallableInput,
|
input: CapturedPageCallableInput,
|
||||||
owner?: DeepCaptureOwner,
|
owner?: DeepCaptureOwner,
|
||||||
|
): Promise<BrowserPageCallable> {
|
||||||
|
if (input.strategy === 'request-transaction') {
|
||||||
|
return serializeTabExecution(target.tabId, () => withPageNetworkGuard(
|
||||||
|
target, [], () => captureAndResume(target, callFrameId, input, owner), input.transaction.request.url,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return captureAndResume(target, callFrameId, input, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureAndResume(
|
||||||
|
target: BrowserTarget,
|
||||||
|
callFrameId: string,
|
||||||
|
input: CapturedPageCallableInput,
|
||||||
|
owner?: DeepCaptureOwner,
|
||||||
): Promise<BrowserPageCallable> {
|
): Promise<BrowserPageCallable> {
|
||||||
let callable: BrowserPageCallable | undefined;
|
let callable: BrowserPageCallable | undefined;
|
||||||
let captureError: unknown;
|
let captureError: unknown;
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export const recordingCapabilityHandler: CapabilityDomainHandler = {
|
|||||||
return createRecordedPageCallable(target, {
|
return createRecordedPageCallable(target, {
|
||||||
callHandleId: String(input.callHandleId || ''),
|
callHandleId: String(input.callHandleId || ''),
|
||||||
name: String(input.name || ''),
|
name: String(input.name || ''),
|
||||||
|
dynamicInputPaths: Array.isArray(input.dynamicInputPaths)
|
||||||
|
? input.dynamicInputPaths.map(String)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (method === 'browser.callable.execute') {
|
if (method === 'browser.callable.execute') {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BrowserTransformExecuteInput,
|
BrowserTransformExecuteInput,
|
||||||
BrowserTransformPacket,
|
BrowserTransformPacket,
|
||||||
BrowserTransformValidationExecuteInput,
|
BrowserTransformValidationExecuteInput,
|
||||||
|
BrowserTransformProfileInput,
|
||||||
} from '@/types/models';
|
} from '@/types/models';
|
||||||
import type { CapabilityDomainHandler } from '../capability-context';
|
import type { CapabilityDomainHandler } from '../capability-context';
|
||||||
import { allowedTarget, requireScope } from '../capability-context';
|
import { allowedTarget, requireScope } from '../capability-context';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
getBrowserTransformProfile,
|
getBrowserTransformProfile,
|
||||||
getBrowserTransformRecovery,
|
getBrowserTransformRecovery,
|
||||||
listBrowserTransformProfiles,
|
listBrowserTransformProfiles,
|
||||||
|
saveBrowserTransformProfile,
|
||||||
resetBrowserTransformRecovery,
|
resetBrowserTransformRecovery,
|
||||||
startBrowserTransformRecovery,
|
startBrowserTransformRecovery,
|
||||||
validateBrowserTransformRecovery,
|
validateBrowserTransformRecovery,
|
||||||
@@ -31,6 +33,19 @@ import { TRANSFORM_CAPABILITY_DOMAIN } from '../capability-domains';
|
|||||||
export const transformCapabilityHandler: CapabilityDomainHandler = {
|
export const transformCapabilityHandler: CapabilityDomainHandler = {
|
||||||
...TRANSFORM_CAPABILITY_DOMAIN,
|
...TRANSFORM_CAPABILITY_DOMAIN,
|
||||||
async handle({ method, input, grant }) {
|
async handle({ method, input, grant }) {
|
||||||
|
if (method === 'browser.transform.profile.save') {
|
||||||
|
const profile = input as unknown as BrowserTransformProfileInput;
|
||||||
|
await allowedTarget(grant, profile.target);
|
||||||
|
return saveBrowserTransformProfile(profile);
|
||||||
|
}
|
||||||
|
if (method === 'browser.transform.validation.get') {
|
||||||
|
const draft = await browserTransformValidationById(String(input.validationId || ''));
|
||||||
|
await allowedTarget(grant, draft.profile.target);
|
||||||
|
return {
|
||||||
|
id: draft.id, expiresAt: draft.expiresAt,
|
||||||
|
directions: { request: draft.profile.request.enabled, response: draft.profile.response.enabled },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (method === 'browser.transform.prepare') {
|
if (method === 'browser.transform.prepare') {
|
||||||
requireScope(grant, 'browser.recording.read');
|
requireScope(grant, 'browser.recording.read');
|
||||||
requireScope(grant, 'browser.callable.execute');
|
requireScope(grant, 'browser.callable.execute');
|
||||||
@@ -40,6 +55,15 @@ export const transformCapabilityHandler: CapabilityDomainHandler = {
|
|||||||
input.packet as BrowserTransformPacket,
|
input.packet as BrowserTransformPacket,
|
||||||
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
|
Array.isArray(input.inputPaths) ? input.inputPaths.map(String) : undefined,
|
||||||
typeof input.name === 'string' ? input.name : undefined,
|
typeof input.name === 'string' ? input.name : undefined,
|
||||||
|
{
|
||||||
|
owner: { kind: 'grant', grantId: grant.id, expiresAt: grant.expiresAt },
|
||||||
|
trigger: input.trigger as { captureId: string; nodeId: string } | undefined,
|
||||||
|
authorize: () => {
|
||||||
|
requireScope(grant, 'browser.debugger.control');
|
||||||
|
requireScope(grant, 'browser.dom.read');
|
||||||
|
requireScope(grant, 'browser.dom.write');
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (method === 'browser.packet.compare') {
|
if (method === 'browser.packet.compare') {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { browser, type Browser } from 'wxt/browser';
|
||||||
|
import type { BrowserTarget, BrowserPageCallableTransaction } from '@/types/models';
|
||||||
|
import { ExtensionError } from '@/shared/errors';
|
||||||
|
import { scriptingTarget } from '@/platform/browser/targets';
|
||||||
|
|
||||||
|
const RULE_BASE = 1_000_000;
|
||||||
|
const RULE_LIMIT = RULE_BASE + 10_000;
|
||||||
|
const queues = new Map<number, Promise<unknown>>();
|
||||||
|
let ruleQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
// DNR is tab-scoped: all callables and profiles in that tab share this gate.
|
||||||
|
export function serializeTabExecution<T>(tabId: number, run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = queues.get(tabId) || Promise.resolve();
|
||||||
|
const result = previous.catch(() => undefined).then(run);
|
||||||
|
queues.set(tabId, result);
|
||||||
|
void result.finally(() => { if (queues.get(tabId) === result) queues.delete(tabId); }).catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRules(tabId: number, prerequisites: BrowserPageCallableTransaction['prerequisites']): Promise<number[]> {
|
||||||
|
const run = async () => {
|
||||||
|
const occupied = new Set((await browser.declarativeNetRequest.getSessionRules()).map((rule) => rule.id));
|
||||||
|
let nextId = RULE_BASE;
|
||||||
|
const allocate = () => {
|
||||||
|
while (occupied.has(nextId)) nextId++;
|
||||||
|
if (nextId >= RULE_LIMIT) throw new Error('页面网络隔离规则已满');
|
||||||
|
return nextId++;
|
||||||
|
};
|
||||||
|
const rules: Browser.declarativeNetRequest.Rule[] = [{
|
||||||
|
id: allocate(), priority: 100_000, action: { type: 'block' },
|
||||||
|
condition: {
|
||||||
|
tabIds: [tabId], urlFilter: '*',
|
||||||
|
// Omitting resourceTypes excludes main_frame and lets native form navigation escape.
|
||||||
|
resourceTypes: ['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object',
|
||||||
|
'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other'],
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
for (const step of prerequisites) {
|
||||||
|
const url = new URL(step.url);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('在线前置请求必须使用 HTTP(S)');
|
||||||
|
rules.push({
|
||||||
|
id: allocate(), priority: 100_001, action: { type: 'allow' },
|
||||||
|
condition: {
|
||||||
|
tabIds: [tabId], regexFilter: `^${url.href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
|
||||||
|
requestMethods: [step.method.toLowerCase() as Browser.declarativeNetRequest.RequestMethod],
|
||||||
|
resourceTypes: ['xmlhttprequest'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await browser.declarativeNetRequest.updateSessionRules({ addRules: rules });
|
||||||
|
return rules.map((rule) => rule.id);
|
||||||
|
};
|
||||||
|
const result = ruleQueue.then(run, run);
|
||||||
|
ruleQueue = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withPageNetworkGuard<T>(
|
||||||
|
target: BrowserTarget,
|
||||||
|
prerequisites: BrowserPageCallableTransaction['prerequisites'],
|
||||||
|
run: () => Promise<T>,
|
||||||
|
captureURL?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const ids = await installRules(target.tabId, prerequisites);
|
||||||
|
let blocked: string | undefined;
|
||||||
|
let captured!: () => void;
|
||||||
|
const capturedRequest = new Promise<void>((resolve) => { captured = resolve; });
|
||||||
|
const onError = (details: Browser.webRequest.OnErrorOccurredDetails) => {
|
||||||
|
if (details.tabId === target.tabId && /BLOCKED_BY_CLIENT|NS_ERROR_ABORT/.test(details.error)) {
|
||||||
|
blocked = details.url;
|
||||||
|
if (details.url === captureURL) captured();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
browser.webRequest.onErrorOccurred.addListener(onError, { urls: ['<all_urls>'], tabId: target.tabId });
|
||||||
|
let value: T;
|
||||||
|
try {
|
||||||
|
value = await run();
|
||||||
|
} finally {
|
||||||
|
// Native form navigation is queued in the renderer. Drain it before removing
|
||||||
|
// browser protection, including when the callable failed during rollback.
|
||||||
|
await browser.scripting.executeScript({
|
||||||
|
target: scriptingTarget(target),
|
||||||
|
func: () => new Promise<void>((resolve) => setTimeout(resolve, 0)),
|
||||||
|
}).catch(() => undefined); // A destroyed document has no queued navigation to drain.
|
||||||
|
}
|
||||||
|
if (captureURL) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([capturedRequest, new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error('捕获后未观察到目标请求被浏览器取消,不能确认捕获完成')), 10_000);
|
||||||
|
})]);
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
// Drain browser request-error delivery before reporting a successful replay.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
if (blocked && !captureURL) throw new ExtensionError('callable_network_blocked', `回放尝试绕过页面拦截,浏览器已阻止请求:${blocked}`);
|
||||||
|
return value;
|
||||||
|
} finally {
|
||||||
|
browser.webRequest.onErrorOccurred.removeListener(onError);
|
||||||
|
await browser.declarativeNetRequest.updateSessionRules({ removeRuleIds: ids });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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,11 +1,23 @@
|
|||||||
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
||||||
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
||||||
|
import { readRequestBody } from '@/shared/request-body'
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
||||||
const MAX_CONTROLS = 2_000
|
const MAX_CONTROLS = 2_000
|
||||||
const MAX_FIELDS = 64
|
const MAX_FIELDS = 64
|
||||||
const MAX_MUTATIONS = 2_000
|
const MAX_MUTATIONS = 2_000
|
||||||
const DEFAULT_TIMEOUT_MS = 4_000
|
const DEFAULT_TIMEOUT_MS = 4_000
|
||||||
|
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis)
|
||||||
|
let executionQueue: Promise<unknown> = Promise.resolve()
|
||||||
|
let observeInput: ((value: unknown) => void) | undefined
|
||||||
|
|
||||||
|
export function observeCallableInput(value: unknown): void { observeInput?.(value) }
|
||||||
|
|
||||||
|
export function serializePageExecution<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
const result = executionQueue.then(run, run)
|
||||||
|
executionQueue = result.catch(() => undefined)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
interface CapturedRequest {
|
interface CapturedRequest {
|
||||||
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
||||||
@@ -24,6 +36,7 @@ export interface RequestTransactionInvocation {
|
|||||||
logicalInput: unknown
|
logicalInput: unknown
|
||||||
invoke(context: TransactionContext): unknown
|
invoke(context: TransactionContext): unknown
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
|
observeInputs?(): () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RollbackController {
|
interface RollbackController {
|
||||||
@@ -44,7 +57,7 @@ function error(message: string): Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function delay(milliseconds: number): Promise<void> {
|
function delay(milliseconds: number): Promise<void> {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
return new Promise((resolve) => nativeSetTimeout(resolve, milliseconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
function absoluteUrl(value: string): string {
|
function absoluteUrl(value: string): string {
|
||||||
@@ -120,6 +133,14 @@ function headerRecord(headers: Headers): Record<string, string> {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedBodyHeaders(body: unknown, headers: Record<string, string>): Record<string, string> {
|
||||||
|
if (body instanceof FormData) return { ...headers, 'content-type': 'application/x-www-form-urlencoded' }
|
||||||
|
if (headers['content-type']) return headers
|
||||||
|
if (body instanceof URLSearchParams) return { ...headers, 'content-type': 'application/x-www-form-urlencoded' }
|
||||||
|
if (body instanceof Blob && body.type) return { ...headers, 'content-type': body.type }
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
function parseForm(value: string): Record<string, string | string[]> {
|
function parseForm(value: string): Record<string, string | string[]> {
|
||||||
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
||||||
for (const [key, item] of new URLSearchParams(value)) {
|
for (const [key, item] of new URLSearchParams(value)) {
|
||||||
@@ -285,11 +306,21 @@ function setControlValue(control: MutableControl, value: unknown): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ('value' in control) {
|
if ('value' in control) {
|
||||||
control.value = value === undefined || value === null ? ''
|
const text = value === undefined || value === null ? ''
|
||||||
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||||
|
let prototype = Object.getPrototypeOf(control)
|
||||||
|
while (prototype && !Object.getOwnPropertyDescriptor(prototype, 'value')?.set) prototype = Object.getPrototypeOf(prototype)
|
||||||
|
const setter = prototype && Object.getOwnPropertyDescriptor(prototype, 'value')?.set
|
||||||
|
if (setter) setter.call(control, text)
|
||||||
|
else control.value = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyControl(control: MutableControl): void {
|
||||||
|
control.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
||||||
|
control.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
||||||
|
}
|
||||||
|
|
||||||
function bindLogicalInput(value: unknown): number {
|
function bindLogicalInput(value: unknown): number {
|
||||||
const fields = logicalFields(value)
|
const fields = logicalFields(value)
|
||||||
if (!fields.length) return 0
|
if (!fields.length) return 0
|
||||||
@@ -304,7 +335,9 @@ function bindLogicalInput(value: unknown): number {
|
|||||||
missing.push(field.path)
|
missing.push(field.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates.forEach((control) => setControlValue(control, field.value))
|
const owners = new Set(candidates.map((control) => control.closest('form') || document))
|
||||||
|
if (owners.size > 1) throw error(`明文字段 ${field.path} 对应多个表单,不能猜测输入目标`)
|
||||||
|
candidates.forEach((control) => { setControlValue(control, field.value); notifyControl(control) })
|
||||||
matched += 1
|
matched += 1
|
||||||
}
|
}
|
||||||
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
||||||
@@ -342,9 +375,11 @@ function beginDomRollback(): RollbackController {
|
|||||||
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
for (const snapshot of controlSnapshots) {
|
for (const snapshot of controlSnapshots) {
|
||||||
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
|
const changed = snapshot.control.value !== snapshot.value || snapshot.control.checked !== snapshot.checked
|
||||||
|
if (snapshot.value !== undefined) setControlValue(snapshot.control, snapshot.value)
|
||||||
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
||||||
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
||||||
|
if (changed) notifyControl(snapshot.control)
|
||||||
}
|
}
|
||||||
for (const mutation of [...mutations].reverse()) {
|
for (const mutation of [...mutations].reverse()) {
|
||||||
try {
|
try {
|
||||||
@@ -372,9 +407,10 @@ function setMethod<T extends object, K extends keyof T>(target: T, key: K, value
|
|||||||
const previous = target[key]
|
const previous = target[key]
|
||||||
try {
|
try {
|
||||||
target[key] = value
|
target[key] = value
|
||||||
|
if (target[key] !== value) throw new Error('属性不可写')
|
||||||
restorers.push(() => { target[key] = previous })
|
restorers.push(() => { target[key] = previous })
|
||||||
} catch {
|
} catch (reason) {
|
||||||
// A non-writable optional boundary remains protected by the other installed boundaries.
|
throw error(`不能隔离页面边界 ${String(key)}:${String(reason)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,17 +430,46 @@ function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): Cap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
export function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
||||||
|
return serializePageExecution(() => runRequestTransaction(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
||||||
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
||||||
const rollback = beginDomRollback()
|
const rollback = beginDomRollback()
|
||||||
const restorers: Array<() => void> = []
|
const restorers: Array<() => void> = []
|
||||||
|
const transactionAbort = new AbortController()
|
||||||
|
let cancelTasks: (() => void) | undefined
|
||||||
|
let restoreObservation: (() => void) | undefined
|
||||||
|
try {
|
||||||
|
cancelTasks = trackInvocationTasks()
|
||||||
|
restoreObservation = input.observeInputs?.()
|
||||||
|
const fields = logicalFields(input.logicalInput)
|
||||||
|
if (!fields.length) {
|
||||||
|
let value = input.logicalInput
|
||||||
|
if (typeof value === 'string') { try { value = JSON.parse(value) } catch { /* Raw plaintext. */ } }
|
||||||
|
fields.push({ path: '', key: '', value })
|
||||||
|
}
|
||||||
|
const consumed = new Set<string>()
|
||||||
|
observeInput = (value) => {
|
||||||
|
if (value instanceof ArrayBuffer) value = new TextDecoder().decode(value)
|
||||||
|
else if (ArrayBuffer.isView(value)) value = new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try { value = JSON.parse(value) } catch { /* Individual plaintext arguments are also supported. */ }
|
||||||
|
}
|
||||||
|
for (const field of fields) {
|
||||||
|
const actual = value && typeof value === 'object' ? readOwnPath(value, field.path) : value
|
||||||
|
try {
|
||||||
|
if (JSON.stringify(actual) === JSON.stringify(field.value)) consumed.add(field.path)
|
||||||
|
} catch { /* Opaque crypto values do not prove an input binding. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
let captured: CapturedRequest | undefined
|
let captured: CapturedRequest | undefined
|
||||||
let captureFailure: Error | undefined
|
let captureFailure: Error | undefined
|
||||||
let prerequisiteIndex = 0
|
let prerequisiteIndex = 0
|
||||||
let prerequisiteInFlight = false
|
let prerequisiteInFlight = false
|
||||||
let resolveCapture!: () => void
|
let resolveCapture!: () => void
|
||||||
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
||||||
const transactionAbort = new AbortController()
|
|
||||||
|
|
||||||
const fail = (reason: unknown): Error => {
|
const fail = (reason: unknown): Error => {
|
||||||
const message = reason instanceof Error ? reason.message : String(reason)
|
const message = reason instanceof Error ? reason.message : String(reason)
|
||||||
@@ -428,6 +493,9 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
||||||
throw fail('页面生成的请求 Body 超过 8 MiB')
|
throw fail('页面生成的请求 Body 超过 8 MiB')
|
||||||
}
|
}
|
||||||
|
observeInput?.(capturedBody(request))
|
||||||
|
const missing = fields.filter((field) => !consumed.has(field.path))
|
||||||
|
if (missing.length) throw fail(`未证明新明文进入转换:${missing.map((field) => field.path || 'body').join('、')};页面可能仍在使用旧状态`)
|
||||||
captured = request
|
captured = request
|
||||||
resolveCapture()
|
resolveCapture()
|
||||||
}
|
}
|
||||||
@@ -435,12 +503,13 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
const previousFetch = window.fetch
|
const previousFetch = window.fetch
|
||||||
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
|
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||||
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
|
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
|
||||||
|
const body = await readRequestBody(request, MAX_BODY_BYTES)
|
||||||
const observed: CapturedRequest = {
|
const observed: CapturedRequest = {
|
||||||
boundary: 'fetch',
|
boundary: 'fetch',
|
||||||
method: request.method.toUpperCase(),
|
method: request.method.toUpperCase(),
|
||||||
url: request.url,
|
url: request.url,
|
||||||
headers: headerRecord(request.headers),
|
headers: { ...headerRecord(request.headers), 'content-type': body.contentType },
|
||||||
bodyText: await request.clone().text(),
|
bodyText: body.text,
|
||||||
}
|
}
|
||||||
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
|
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
|
||||||
if (prerequisite) {
|
if (prerequisite) {
|
||||||
@@ -495,7 +564,8 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||||
const metadata = xhrMetadata.get(this)
|
const metadata = xhrMetadata.get(this)
|
||||||
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
||||||
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
|
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata,
|
||||||
|
headers: normalizedBodyHeaders(body, metadata.headers), bodyText: text })).catch((reason) => {
|
||||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||||
resolveCapture()
|
resolveCapture()
|
||||||
})
|
})
|
||||||
@@ -504,7 +574,7 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
if (typeof navigator.sendBeacon === 'function') {
|
if (typeof navigator.sendBeacon === 'function') {
|
||||||
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
||||||
void bodyText(data).then((text) => capture({
|
void bodyText(data).then((text) => capture({
|
||||||
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
|
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: normalizedBodyHeaders(data, {}), bodyText: text,
|
||||||
})).catch((reason) => {
|
})).catch((reason) => {
|
||||||
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
||||||
resolveCapture()
|
resolveCapture()
|
||||||
@@ -537,8 +607,8 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
|
|
||||||
let invocationFailure: unknown
|
let invocationFailure: unknown
|
||||||
let returned: unknown
|
let returned: unknown
|
||||||
try {
|
|
||||||
const domInputCount = bindLogicalInput(input.logicalInput)
|
const domInputCount = bindLogicalInput(input.logicalInput)
|
||||||
|
if (domInputCount) await delay(0)
|
||||||
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
||||||
invocationFailure = reason
|
invocationFailure = reason
|
||||||
resolveCapture()
|
resolveCapture()
|
||||||
@@ -570,19 +640,28 @@ export async function executeRequestTransaction(input: RequestTransactionInvocat
|
|||||||
return value
|
return value
|
||||||
} finally {
|
} finally {
|
||||||
if (!transactionAbort.signal.aborted) transactionAbort.abort(error('请求事务已经结束'))
|
if (!transactionAbort.signal.aborted) transactionAbort.abort(error('请求事务已经结束'))
|
||||||
for (const restore of restorers.reverse()) {
|
try { rollback.finish() } finally {
|
||||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
observeInput = undefined
|
||||||
|
for (const restore of [restoreObservation, cancelTasks, ...restorers.reverse()]) {
|
||||||
|
try { restore?.() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rollback.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeSideEffectFreeCallable(
|
export function executeSideEffectFreeCallable(
|
||||||
invoke: () => unknown,
|
invoke: () => unknown,
|
||||||
execution: BrowserPageCallableExecutionPolicy,
|
execution: BrowserPageCallableExecutionPolicy,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
|
return serializePageExecution(() => runSideEffectFreeCallable(invoke, execution))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSideEffectFreeCallable(invoke: () => unknown, execution: BrowserPageCallableExecutionPolicy): Promise<unknown> {
|
||||||
const rollback = beginDomRollback()
|
const rollback = beginDomRollback()
|
||||||
|
let cancelTasks: (() => void) | undefined
|
||||||
const restorers: Array<() => void> = []
|
const restorers: Array<() => void> = []
|
||||||
|
try {
|
||||||
|
cancelTasks = trackInvocationTasks()
|
||||||
let attemptedBoundary = ''
|
let attemptedBoundary = ''
|
||||||
const block = (boundary: string): never => {
|
const block = (boundary: string): never => {
|
||||||
attemptedBoundary = boundary
|
attemptedBoundary = boundary
|
||||||
@@ -602,7 +681,6 @@ export async function executeSideEffectFreeCallable(
|
|||||||
}
|
}
|
||||||
document.addEventListener('submit', submitListener, true)
|
document.addEventListener('submit', submitListener, true)
|
||||||
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
||||||
try {
|
|
||||||
const value = await settleCallableResult(invoke(), execution)
|
const value = await settleCallableResult(invoke(), execution)
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
||||||
@@ -610,9 +688,42 @@ export async function executeSideEffectFreeCallable(
|
|||||||
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
||||||
return value
|
return value
|
||||||
} finally {
|
} finally {
|
||||||
|
try { rollback.finish() } finally {
|
||||||
|
cancelTasks?.()
|
||||||
for (const restore of restorers.reverse()) {
|
for (const restore of restorers.reverse()) {
|
||||||
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
||||||
}
|
}
|
||||||
rollback.finish()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackInvocationTasks(): () => void {
|
||||||
|
const restorers: Array<() => void> = []
|
||||||
|
const pending: Array<() => void> = []
|
||||||
|
let active = true
|
||||||
|
const cleanup = () => {
|
||||||
|
active = false
|
||||||
|
for (const action of [...pending, ...restorers.reverse()]) {
|
||||||
|
try { action() } catch { /* Cleanup must attempt every installed hook. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const [schedule, cancel] of [
|
||||||
|
['setTimeout', 'clearTimeout'], ['setInterval', 'clearInterval'],
|
||||||
|
['requestAnimationFrame', 'cancelAnimationFrame'], ['requestIdleCallback', 'cancelIdleCallback'],
|
||||||
|
] as const) {
|
||||||
|
const original = window[schedule] as Function | undefined
|
||||||
|
const clear = window[cancel] as Function | undefined
|
||||||
|
if (!original || !clear) continue
|
||||||
|
setMethod(window, schedule, ((callback: unknown, ...args: unknown[]) => {
|
||||||
|
if (typeof callback !== 'function') throw error('回放不支持字符串定时任务')
|
||||||
|
const id = Reflect.apply(original, window, [(...values: unknown[]) => {
|
||||||
|
if (active) Reflect.apply(callback, window, values)
|
||||||
|
}, ...args])
|
||||||
|
pending.push(() => Reflect.apply(clear, window, [id]))
|
||||||
|
return id
|
||||||
|
}) as never, restorers)
|
||||||
|
}
|
||||||
|
} catch (reason) { cleanup(); throw reason }
|
||||||
|
return cleanup
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { resolveDocumentTarget, scriptingTarget } from '@/platform/browser/targe
|
|||||||
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
|
import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants';
|
||||||
import { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client';
|
import { executeFirefoxPageRecorderCommand } from '@/features/browser-recording/bridge-client';
|
||||||
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
import { normalizeBrowserRecordingCrypto } from '@/features/browser-crypto/model';
|
||||||
|
import { serializeTabExecution, withPageNetworkGuard } from './network-guard';
|
||||||
import {
|
import {
|
||||||
MAX_CALLABLE_TIMEOUT_MS,
|
MAX_CALLABLE_TIMEOUT_MS,
|
||||||
MIN_CALLABLE_TIMEOUT_MS,
|
MIN_CALLABLE_TIMEOUT_MS,
|
||||||
@@ -241,13 +242,39 @@ async function pageCallableCommand(
|
|||||||
if (command === 'callable.list') return [];
|
if (command === 'callable.list') return [];
|
||||||
throw new Error('页面函数控制器不存在,页面可能已经刷新');
|
throw new Error('页面函数控制器不存在,页面可能已经刷新');
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
return await Promise.resolve(controller.command(command, input));
|
return await Promise.resolve(controller.command(command, input));
|
||||||
|
} catch (error) {
|
||||||
|
return { __yakitCallError: {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
} };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callPageController(
|
async function callPageController(
|
||||||
target: BrowserTarget,
|
target: BrowserTarget,
|
||||||
command: PageControllerCommand,
|
command: PageControllerCommand,
|
||||||
input: Record<string, unknown> = {},
|
input: Record<string, unknown> = {},
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (command === 'callable.execute' || command === 'transform.execute') {
|
||||||
|
return serializeTabExecution(target.tabId, async () => {
|
||||||
|
const current = await resolveDocumentTarget(target);
|
||||||
|
const callables = await callPageController(current, 'callable.list') as RawCallable[];
|
||||||
|
const ids = command === 'callable.execute' ? [input.callableId]
|
||||||
|
: (input.direction as BrowserTransformDirection).nodes.flatMap((node) => node.kind === 'page.call' ? [node.callableId] : []);
|
||||||
|
const used = callables.filter((callable) => ids.includes(callable.id));
|
||||||
|
if (used.length !== new Set(ids).size) throw new ExtensionError('callable_unavailable', '页面函数已经失效');
|
||||||
|
const prerequisites = used.flatMap((callable) => callable.transaction?.prerequisites || []);
|
||||||
|
return withPageNetworkGuard(current, prerequisites, () => invokePageController(current, command, input));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invokePageController(target, command, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokePageController(
|
||||||
|
target: BrowserTarget,
|
||||||
|
command: PageControllerCommand,
|
||||||
|
input: Record<string, unknown>,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
|
if (import.meta.env.FIREFOX) return executeFirefoxPageRecorderCommand(target, command, input);
|
||||||
const [result] = await browser.scripting.executeScript({
|
const [result] = await browser.scripting.executeScript({
|
||||||
@@ -260,6 +287,8 @@ async function callPageController(
|
|||||||
if (injectionError !== undefined) {
|
if (injectionError !== undefined) {
|
||||||
throw new ExtensionError('page_callable_execution_failed', injectionErrorMessage(injectionError));
|
throw new ExtensionError('page_callable_execution_failed', injectionErrorMessage(injectionError));
|
||||||
}
|
}
|
||||||
|
const failure = (result?.result as { __yakitCallError?: { message: string } } | undefined)?.__yakitCallError;
|
||||||
|
if (failure) throw new ExtensionError('page_callable_execution_failed', failure.message);
|
||||||
return result?.result;
|
return result?.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ describe('Bridge v3 protocol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('accepts automatic selected-frame capture and rejects the legacy expression contract', () => {
|
it('accepts automatic selected-frame capture and rejects the legacy expression contract', () => {
|
||||||
|
expect(parseCapabilityParams('browser.callable.create', {
|
||||||
|
source: 'recording', callHandleId: 'call-1', name: 'Dynamic decrypt',
|
||||||
|
dynamicInputPaths: ['$input', '$input.key', '$input.iv'],
|
||||||
|
})).toMatchObject({ dynamicInputPaths: ['$input', '$input.key', '$input.iv'] });
|
||||||
expect(parseCapabilityParams('browser.callable.create', {
|
expect(parseCapabilityParams('browser.callable.create', {
|
||||||
source: 'deep-capture', strategy: 'selected-frame', callFrameId: 'frame-1', name: 'Envelope',
|
source: 'deep-capture', strategy: 'selected-frame', callFrameId: 'frame-1', name: 'Envelope',
|
||||||
candidateId: 'candidate-envelope',
|
candidateId: 'candidate-envelope',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { BridgePublicKey } from '@/types/models';
|
|||||||
import {
|
import {
|
||||||
browserTransformExecuteSchema,
|
browserTransformExecuteSchema,
|
||||||
browserTransformPacketSchema,
|
browserTransformPacketSchema,
|
||||||
|
browserTransformProfileInputSchema,
|
||||||
} from './transform';
|
} from './transform';
|
||||||
|
|
||||||
export const BRIDGE_PROTOCOL_VERSION = 3;
|
export const BRIDGE_PROTOCOL_VERSION = 3;
|
||||||
@@ -183,6 +184,7 @@ export const capabilityParams = {
|
|||||||
v.strictObject({
|
v.strictObject({
|
||||||
...targetFields, source: v.literal('recording'), callHandleId: id,
|
...targetFields, source: v.literal('recording'), callHandleId: id,
|
||||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||||
|
dynamicInputPaths: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))), v.maxLength(16))),
|
||||||
}),
|
}),
|
||||||
v.strictObject({
|
v.strictObject({
|
||||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||||
@@ -239,10 +241,12 @@ export const capabilityParams = {
|
|||||||
'browser.transform.prepare': v.strictObject({
|
'browser.transform.prepare': v.strictObject({
|
||||||
...targetFields,
|
...targetFields,
|
||||||
candidateId: id,
|
candidateId: id,
|
||||||
|
trigger: v.optional(v.strictObject({ captureId: id, nodeId: id })),
|
||||||
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
|
inputPaths: v.optional(v.pipe(v.array(valuePath), v.maxLength(64))),
|
||||||
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
name: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120))),
|
||||||
packet: browserTransformPacketSchema,
|
packet: browserTransformPacketSchema,
|
||||||
}),
|
}),
|
||||||
|
'browser.transform.validation.get': v.strictObject({ validationId: id }),
|
||||||
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
'browser.deep_capture.start': v.strictObject({ ...targetFields, matcher: deepCaptureMatcher }),
|
||||||
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
'browser.deep_capture.status': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
'browser.deep_capture.keepalive': v.optional(v.strictObject(targetFields)),
|
||||||
@@ -250,6 +254,7 @@ export const capabilityParams = {
|
|||||||
'browser.deep_capture.detach': v.optional(v.strictObject(targetFields)),
|
'browser.deep_capture.detach': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
|
'browser.transform.profile.list': v.optional(v.strictObject(targetFields)),
|
||||||
'browser.transform.profile.delete': v.strictObject({ id }),
|
'browser.transform.profile.delete': v.strictObject({ id }),
|
||||||
|
'browser.transform.profile.save': browserTransformProfileInputSchema,
|
||||||
'browser.transform.recovery.get': v.strictObject({ id }),
|
'browser.transform.recovery.get': v.strictObject({ id }),
|
||||||
'browser.transform.recovery.start': v.strictObject({ id }),
|
'browser.transform.recovery.start': v.strictObject({ id }),
|
||||||
'browser.transform.recovery.capture': v.strictObject({
|
'browser.transform.recovery.capture': v.strictObject({
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ describe('versioned Bridge capability catalog', () => {
|
|||||||
expect(capabilityVisibleToAgent('browser.handoff.resolve')).toBe(false);
|
expect(capabilityVisibleToAgent('browser.handoff.resolve')).toBe(false);
|
||||||
expect(capabilityVisibleToAgent('browser.thumbnail')).toBe(false);
|
expect(capabilityVisibleToAgent('browser.thumbnail')).toBe(false);
|
||||||
expect(capabilityVisibleToAgent('browser.context')).toBe(true);
|
expect(capabilityVisibleToAgent('browser.context')).toBe(true);
|
||||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.profile.save')).toBeUndefined();
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.profile.save')).toMatchObject({
|
||||||
|
access: 'write', scopes: ['browser.transform.manage'],
|
||||||
|
});
|
||||||
|
expect(capabilityVisibleToAgent('browser.transform.profile.save')).toBe(true);
|
||||||
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.transform.validation.get')).toMatchObject({
|
||||||
|
access: 'read', scopes: ['browser.transform.read'],
|
||||||
|
});
|
||||||
expect(catalog.capabilities.find((capability) => capability.method === 'proxy.switch')?.summary)
|
expect(catalog.capabilities.find((capability) => capability.method === 'proxy.switch')?.summary)
|
||||||
.toContain('不会生成、启用或执行 Transform Profile');
|
.toContain('不会生成、启用或执行 Transform Profile');
|
||||||
expect(catalog.capabilities.find((capability) => capability.method === 'browser.profile.validate')?.summary)
|
expect(catalog.capabilities.find((capability) => capability.method === 'browser.profile.validate')?.summary)
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ const CAPABILITY_METADATA = {
|
|||||||
domain: 'transform', access: 'write', summary: '删除 Transform Profile',
|
domain: 'transform', access: 'write', summary: '删除 Transform Profile',
|
||||||
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.transform.manage'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
|
'browser.transform.profile.save': {
|
||||||
|
domain: 'transform', access: 'write', summary: '保存或更新经验证的明文网关;仅在用户需要持久保存时使用,普通测试优先短时草稿',
|
||||||
|
scopes: ['browser.transform.manage'], targetMode: 'none', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
'browser.transform.recovery.get': {
|
'browser.transform.recovery.get': {
|
||||||
domain: 'transform', access: 'read', summary: '读取 Profile 的非敏感文档恢复计划和确定性状态',
|
domain: 'transform', access: 'read', summary: '读取 Profile 的非敏感文档恢复计划和确定性状态',
|
||||||
scopes: ['browser.transform.read'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
|
scopes: ['browser.transform.read'], targetMode: 'profile', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
@@ -313,10 +317,14 @@ const CAPABILITY_METADATA = {
|
|||||||
},
|
},
|
||||||
'browser.transform.prepare': {
|
'browser.transform.prepare': {
|
||||||
domain: 'transform', access: 'execute',
|
domain: 'transform', access: 'execute',
|
||||||
summary: '将 browser.crypto.inspect 捕获的候选原子化编译并验证为短时明文转换;不需要 Agent 操作录制、页面函数或 Profile 底层步骤',
|
summary: '从 browser.crypto.inspect 候选准备双向短时网关;自动重触发原操作并捕获缺失业务方向,无需插件 UI;节点变化时可传入新的 trigger',
|
||||||
scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'],
|
scopes: ['browser.transform.execute', 'browser.recording.read', 'browser.callable.execute'],
|
||||||
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
|
'browser.transform.validation.get': {
|
||||||
|
domain: 'transform', access: 'read', summary: '读取短时网关的有效期和已启用方向;不返回页面闭包或明文样本',
|
||||||
|
scopes: ['browser.transform.read'], targetMode: 'none', defaultTimeoutMs: READ_TIMEOUT_MS,
|
||||||
|
},
|
||||||
'browser.invoke': {
|
'browser.invoke': {
|
||||||
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
|
domain: 'page', access: 'dangerous', summary: '在页面 MAIN world 调用具名函数路径',
|
||||||
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
scopes: ['browser.page.invoke'], targetMode: 'document', defaultTimeoutMs: REPLAY_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ const payloadSchemas = {
|
|||||||
v.strictObject({
|
v.strictObject({
|
||||||
...targetFields, source: v.literal('recording'), callHandleId: id,
|
...targetFields, source: v.literal('recording'), callHandleId: id,
|
||||||
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
name: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(120)),
|
||||||
|
dynamicInputPaths: v.optional(v.pipe(v.array(v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(160))), v.maxLength(16))),
|
||||||
}),
|
}),
|
||||||
v.strictObject({
|
v.strictObject({
|
||||||
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
...targetFields, source: v.literal('deep-capture'), callFrameId: id,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export async function readRequestBody(request: Request, maximumBytes = 8 * 1024 * 1024): Promise<{
|
||||||
|
text: string;
|
||||||
|
value: string | FormData;
|
||||||
|
contentType: string;
|
||||||
|
}> {
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
const reader = request.clone().body?.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let length = 0;
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) break;
|
||||||
|
length += next.value.byteLength;
|
||||||
|
if (length > maximumBytes) {
|
||||||
|
void reader.cancel().catch(() => undefined);
|
||||||
|
throw new Error(`请求 Body 超过 ${maximumBytes} B`);
|
||||||
|
}
|
||||||
|
chunks.push(next.value);
|
||||||
|
}
|
||||||
|
} finally { reader.releaseLock(); }
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
||||||
|
if (contentType.toLowerCase().includes('multipart/form-data')) {
|
||||||
|
const value = await new Response(bytes, { headers: { 'content-type': contentType } }).formData();
|
||||||
|
const fields = new URLSearchParams();
|
||||||
|
for (const [key, item] of value) {
|
||||||
|
if (typeof item !== 'string') throw new Error(`表单字段 ${key} 包含文件,暂不允许自动回放`);
|
||||||
|
fields.append(key, item);
|
||||||
|
}
|
||||||
|
return { text: fields.toString(), value, contentType: 'application/x-www-form-urlencoded' };
|
||||||
|
}
|
||||||
|
const text = new TextDecoder().decode(bytes);
|
||||||
|
return { text, value: text, contentType };
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ export interface ExtensionRequestMap {
|
|||||||
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
|
'recording.clear': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
|
||||||
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
|
'recording.stop': { input: { tabId?: number; frameId?: number; documentId?: string }; output: BrowserRecordingSnapshot };
|
||||||
'callable.create': { input: ({ tabId?: number; frameId?: number; documentId?: string } & (
|
'callable.create': { input: ({ tabId?: number; frameId?: number; documentId?: string } & (
|
||||||
| { source: 'recording'; callHandleId: string; name: string }
|
| { source: 'recording'; callHandleId: string; name: string; dynamicInputPaths?: string[] }
|
||||||
| { source: 'deep-capture'; strategy: 'selected-frame'; callFrameId: string; name?: string; candidateId?: string }
|
| { source: 'deep-capture'; strategy: 'selected-frame'; callFrameId: string; name?: string; candidateId?: string }
|
||||||
| { source: 'deep-capture'; strategy: 'request-transaction'; callFrameId: string; name?: string; candidateId: string }
|
| { source: 'deep-capture'; strategy: 'request-transaction'; callFrameId: string; name?: string; candidateId: string }
|
||||||
| { source: 'deep-capture'; strategy: 'expression'; callFrameId: string; name: string; functionExpression: string }
|
| { source: 'deep-capture'; strategy: 'expression'; callFrameId: string; name: string; functionExpression: string }
|
||||||
|
|||||||
+7
-2
@@ -844,6 +844,7 @@ export interface BrowserProfileInferenceSource {
|
|||||||
operation: string;
|
operation: string;
|
||||||
crypto?: BrowserRecordingCrypto;
|
crypto?: BrowserRecordingCrypto;
|
||||||
callHandleId?: string;
|
callHandleId?: string;
|
||||||
|
dynamicInputPaths?: string[];
|
||||||
arguments: BrowserRecordingCallArgument[];
|
arguments: BrowserRecordingCallArgument[];
|
||||||
destination?: string;
|
destination?: string;
|
||||||
serialization?: BrowserProfileInferenceSerialization;
|
serialization?: BrowserProfileInferenceSerialization;
|
||||||
@@ -868,6 +869,7 @@ export interface BrowserProfileInferenceCandidate {
|
|||||||
id: string;
|
id: string;
|
||||||
recordingId: string;
|
recordingId: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
transactionId?: string;
|
||||||
target: BrowserTarget;
|
target: BrowserTarget;
|
||||||
direction: 'request' | 'response';
|
direction: 'request' | 'response';
|
||||||
request: {
|
request: {
|
||||||
@@ -927,6 +929,7 @@ export interface BrowserDeepCaptureFrame {
|
|||||||
sourceMapUrl?: string;
|
sourceMapUrl?: string;
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
columnNumber: number;
|
columnNumber: number;
|
||||||
|
functionLocation?: { lineNumber: number; columnNumber: number };
|
||||||
scopes: BrowserDeepCaptureScope[];
|
scopes: BrowserDeepCaptureScope[];
|
||||||
thisPreview: string;
|
thisPreview: string;
|
||||||
sourceKind: 'page' | 'extension-hook' | 'library';
|
sourceKind: 'page' | 'extension-hook' | 'library';
|
||||||
@@ -936,7 +939,7 @@ export interface BrowserDeepCaptureFrame {
|
|||||||
parameterCount?: number;
|
parameterCount?: number;
|
||||||
parameterNames?: string[];
|
parameterNames?: string[];
|
||||||
riskFlags: Array<'network' | 'dom' | 'navigation' | 'storage'>;
|
riskFlags: Array<'network' | 'dom' | 'navigation' | 'storage'>;
|
||||||
resolution?: 'frame-name' | 'receiver-method' | 'scope-binding' | 'manual-expression';
|
resolution?: 'frame-name' | 'receiver-method' | 'scope-binding' | 'current-function' | 'event-listener' | 'manual-expression';
|
||||||
referenceExpression?: string;
|
referenceExpression?: string;
|
||||||
candidateCount?: number;
|
candidateCount?: number;
|
||||||
};
|
};
|
||||||
@@ -1335,7 +1338,9 @@ export interface BrowserTransformProfileValidationResult {
|
|||||||
generated: BrowserTransformPacket;
|
generated: BrowserTransformPacket;
|
||||||
execution: BrowserTransformExecution;
|
execution: BrowserTransformExecution;
|
||||||
comparison?: BrowserPacketComparison;
|
comparison?: BrowserPacketComparison;
|
||||||
validationDraft?: Pick<BrowserTransformValidationDraft, 'contractVersion' | 'id' | 'createdAt' | 'expiresAt'>;
|
validationDraft?: Pick<BrowserTransformValidationDraft, 'contractVersion' | 'id' | 'createdAt' | 'expiresAt'> & {
|
||||||
|
directions: { request: boolean; response: boolean };
|
||||||
|
};
|
||||||
next: string;
|
next: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user