From 601f8acbcf6ff8380345a61ab24d091c63f02ebb Mon Sep 17 00:00:00 2001 From: go0p Date: Thu, 17 Sep 2026 17:24:54 +0800 Subject: [PATCH] fix(capture): automate safe bidirectional gateways for users and agents --- package.json | 2 + scripts/verify-agent-gateway.mjs | 113 +++++ scripts/verify-capture-regressions.mjs | 142 ++++++ scripts/verify-login-gateway.mjs | 103 +++++ src/entrypoints/options/App.css | 8 + src/entrypoints/options/App.tsx | 1 - src/entrypoints/page-recorder-main-world.ts | 61 ++- src/features/browser-analysis/service.test.ts | 1 + src/features/browser-analysis/service.ts | 215 +++++++-- .../browser-crypto/adapters/adapters.test.ts | 30 +- .../browser-crypto/adapters/contract.ts | 7 + .../browser-crypto/adapters/cryptojs.ts | 61 ++- src/features/browser-crypto/inspect.test.ts | 30 ++ src/features/browser-crypto/inspect.ts | 33 +- .../browser-inference/inference.test.ts | 109 +++++ src/features/browser-inference/inference.ts | 58 ++- .../browser-recording/RecordingWorkspace.tsx | 134 +++++- .../main-world/boundaries/network.ts | 14 +- .../main-world/trace.test.ts | 12 + .../browser-recording/main-world/trace.ts | 14 +- .../browser-recording/presentation.test.ts | 52 +++ .../browser-recording/presentation.ts | 63 +++ src/features/browser-recording/service.ts | 2 +- .../BrowserTransformWorkspace.tsx | 13 +- .../TransformProfileRail.tsx | 5 +- src/features/browser-transform/guided.test.ts | 32 ++ src/features/browser-transform/guided.ts | 25 +- .../browser-transform/profile-draft.test.ts | 103 ++++- .../browser-transform/profile-draft.ts | 56 ++- .../deep-capture/DeepCaptureWorkspace.tsx | 41 +- .../deep-capture/business-frame-ranker.ts | 33 +- src/features/deep-capture/matcher.ts | 15 + src/features/deep-capture/service.test.ts | 78 +++- src/features/deep-capture/service.ts | 145 ++++-- .../grants/capability-handlers/recording.ts | 3 + .../grants/capability-handlers/transform.ts | 24 + src/features/page-callable/network-guard.ts | 103 +++++ .../request-transaction.runtime.test.ts | 149 ------ .../page-callable/request-transaction.ts | 429 +++++++++++------- src/features/page-callable/service.ts | 31 +- src/protocol/bridge.test.ts | 4 + src/protocol/bridge.ts | 5 + src/protocol/capabilities.test.ts | 8 +- src/protocol/capabilities.ts | 10 +- src/protocol/extension.ts | 1 + src/shared/request-body.ts | 38 ++ src/types/messages.ts | 2 +- src/types/models.ts | 9 +- 48 files changed, 2111 insertions(+), 516 deletions(-) create mode 100644 scripts/verify-agent-gateway.mjs create mode 100644 scripts/verify-capture-regressions.mjs create mode 100644 scripts/verify-login-gateway.mjs create mode 100644 src/features/browser-recording/presentation.test.ts create mode 100644 src/features/browser-recording/presentation.ts create mode 100644 src/features/deep-capture/matcher.ts create mode 100644 src/features/page-callable/network-guard.ts delete mode 100644 src/features/page-callable/request-transaction.runtime.test.ts create mode 100644 src/shared/request-body.ts diff --git a/package.json b/package.json index bb7a88f..d6eb176 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "verify:aesrsa": "node scripts/verify-aesrsa-transaction.mjs", "verify:aesserver": "node scripts/verify-aesserver-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: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", diff --git a/scripts/verify-agent-gateway.mjs b/scripts/verify-agent-gateway.mjs new file mode 100644 index 0000000..33cb1e1 --- /dev/null +++ b/scripts/verify-agent-gateway.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)}`) +} diff --git a/scripts/verify-capture-regressions.mjs b/scripts/verify-capture-regressions.mjs new file mode 100644 index 0000000..207ccb0 --- /dev/null +++ b/scripts/verify-capture-regressions.mjs @@ -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('
') + } +}) +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)) +} diff --git a/scripts/verify-login-gateway.mjs b/scripts/verify-login-gateway.mjs new file mode 100644 index 0000000..469df93 --- /dev/null +++ b/scripts/verify-login-gateway.mjs @@ -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() } diff --git a/src/entrypoints/options/App.css b/src/entrypoints/options/App.css index bad30c2..5354422 100644 --- a/src/entrypoints/options/App.css +++ b/src/entrypoints/options/App.css @@ -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 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.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 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; } @@ -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 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-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.is-medium { border-top-color: var(--warning); } .profile-inference.is-low { border-top-color: var(--border-strong); } diff --git a/src/entrypoints/options/App.tsx b/src/entrypoints/options/App.tsx index f2dd33f..ccdd3e3 100644 --- a/src/entrypoints/options/App.tsx +++ b/src/entrypoints/options/App.tsx @@ -682,7 +682,6 @@ function GatewayWorkspace({ run={run} gatewayShared={bridge.state === 'connected'} onShareGateway={shareTransform} - initialMode="gateway" /> ; } diff --git a/src/entrypoints/page-recorder-main-world.ts b/src/entrypoints/page-recorder-main-world.ts index 027b7ca..37501f0 100644 --- a/src/entrypoints/page-recorder-main-world.ts +++ b/src/entrypoints/page-recorder-main-world.ts @@ -7,7 +7,7 @@ import { type PageRecorderBridgeResponse, } from '@/features/browser-recording/bridge-protocol'; 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 { createCryptoAdapterRuntime, @@ -200,8 +200,13 @@ interface RecordedCallHandle { original: Function; thisArg: unknown; args: unknown[]; - inputIndex: number; - originalInput: unknown; + replayInputs: Array<{ + path: string; + name: string; + role: CallArgumentRole; + originalInput: unknown; + apply(args: unknown[], value: unknown): void; + }>; eventId?: string; traceId?: string; recordingId?: string; @@ -209,7 +214,6 @@ interface RecordedCallHandle { outputDataType?: string; outputEncoding?: PageCallableMetadata['output']['encoding']; resultMode: 'sync' | 'promise'; - adaptInput(value: unknown): unknown; } interface RecorderController { @@ -485,6 +489,7 @@ export default defineUnlistedScript(() => { } const started = performance.now(); const inputIndex = plan.inputIndex; + if (inputIndex >= 0) observeCallableInput(args[inputIndex]); const callHandleId = plan.callableKind && inputIndex >= 0 ? registerHandle({ kind: plan.callableKind, operation: `${plan.crypto.adapterId}.${plan.crypto.operation}`, @@ -492,11 +497,17 @@ export default defineUnlistedScript(() => { original, thisArg, args: [...args], - inputIndex, - originalInput: args[inputIndex], + replayInputs: plan.replayInputs || [{ + path: '$input', + name: 'data', + role: 'data', + originalInput: args[inputIndex], + apply: (nextArgs, value) => { + nextArgs[inputIndex] = (plan.adaptInput || ((input) => defaultAdaptInput(input, args[inputIndex])))(value); + }, + }], outputEncoding: plan.outputEncoding || plan.crypto.outputEncoding, resultMode: operation.resultMode, - adaptInput: plan.adaptInput || ((value) => defaultAdaptInput(value, args[inputIndex])), }) : undefined; const item = observe(() => ({ kind: 'crypto', @@ -728,8 +739,13 @@ export default defineUnlistedScript(() => { return value; } - function createRecordedCallable(handle: RecordedCallHandle, name: string): PageCallableMetadata { + function createRecordedCallable(handle: RecordedCallHandle, name: string, inputPaths?: string[]): PageCallableMetadata { 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 = { id, name: name.trim().slice(0, 120) || handle.operation, @@ -740,15 +756,15 @@ export default defineUnlistedScript(() => { origin: location.origin, lifecycle: 'document', execution: callableExecutionPolicy(handle.resultMode), - inputSlots: [{ - id: 'data', - name: 'data', - index: 0, - role: 'data', - dataType: dataType(handle.originalInput), + inputSlots: resolvedInputs.map((input, index) => ({ + id: input.name, + name: input.name, + index, + role: input.role, + dataType: dataType(input.originalInput), required: true, retained: false, - }], + })), output: { dataType: handle.outputDataType || 'unknown', encoding: handle.outputEncoding || 'auto', @@ -767,9 +783,9 @@ export default defineUnlistedScript(() => { pageCallableRegistry().set(id, { metadata, invoke(values) { - if (!values.length) throw new Error('页面函数缺少 data 参数'); + if (values.length < resolvedInputs.length) throw new Error(`页面函数需要 ${resolvedInputs.length} 个动态参数`); 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); }, }); @@ -786,6 +802,11 @@ export default defineUnlistedScript(() => { logicalInput: values[0], invoke: (context) => entry.invoke(values, context), 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' ? await executeSideEffectFreeCallable(() => entry.invoke(values), entry.metadata.execution) @@ -941,7 +962,11 @@ export default defineUnlistedScript(() => { const callHandleId = String(input.callHandleId || ''); const handle = handles.get(callHandleId); 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.execute') { diff --git a/src/features/browser-analysis/service.test.ts b/src/features/browser-analysis/service.test.ts index 8210d59..f019e51 100644 --- a/src/features/browser-analysis/service.test.ts +++ b/src/features/browser-analysis/service.test.ts @@ -13,6 +13,7 @@ vi.mock('wxt/browser', () => { const event = { addListener: vi.fn() }; return { browser: { + storage: {}, tabs: { onRemoved: event, onCreated: event }, webNavigation: { onBeforeNavigate: event, diff --git a/src/features/browser-analysis/service.ts b/src/features/browser-analysis/service.ts index 99bae56..da86f35 100644 --- a/src/features/browser-analysis/service.ts +++ b/src/features/browser-analysis/service.ts @@ -9,12 +9,20 @@ import { compileGuidedTransform, parseGuidedTransform, } 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 { executePageCallable, listPageCallables } from '@/features/page-callable/service'; import { browserTransformProfileInputSchema } from '@/protocol/transform'; import { ExtensionError } from '@/shared/errors'; 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 { BROWSER_TRANSFORM_AGENT_CONTRACT_VERSION, type ActiveTabInfo, @@ -36,6 +44,7 @@ import { type BrowserTransformProfileValidationResult, type BrowserTransformValidationDraft, type BrowserTransformDirectionName, + type BrowserDeepCaptureMatcher, } from '@/types/models'; const MAX_TRACE_EVENTS = 80; @@ -67,6 +76,8 @@ let callableOutputStorageQueue: Promise = Promise.resolve(); interface BrowserProfileEvidenceReference { candidate: BrowserProfileInferenceCandidate; requestEvent?: BrowserRecordingEvent; + matcher?: BrowserDeepCaptureMatcher; + triggerKey?: string; createdAt: number; expiresAt: number; } @@ -136,7 +147,7 @@ function pruneProfileEvidence( ); } -export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot): Promise { +export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnapshot, triggerKey?: string): Promise { if (!snapshot.profileCandidates.length) return; const scoped = recordingSnapshotForScope(snapshot, false); const events = new Map(scoped.events.map((event) => [event.id, event])); @@ -147,6 +158,8 @@ export async function stageBrowserProfileEvidence(snapshot: BrowserRecordingSnap stored[candidate.id] = { candidate, requestEvent: events.get(candidate.request.eventId), + matcher: eventMatcher(events.get(candidate.capturePlan?.matcherEventId || candidate.source.eventId), candidate), + triggerKey: triggerKey || stored[candidate.id]?.triggerKey, createdAt, expiresAt: createdAt + PROFILE_EVIDENCE_TTL_MS, }; @@ -194,12 +207,14 @@ export async function resolveBrowserProfileCaptureTransaction( } function callableAnalysis(candidate: BrowserProfileInferenceCandidate): BrowserTransformCallableAnalysis { + const sources = [candidate.source, ...candidate.sources] + .filter((source, index, items) => items.findIndex((item) => item.eventId === source.eventId) === index); return { version: 1, traceId: candidate.traceId, confidence: { ...candidate.confidence }, flow: candidate.flow.slice(0, 32), - operations: candidate.sources.slice(0, 16).map((source) => ({ + operations: sources.slice(0, 16).map((source) => ({ operation: source.operation, destination: source.destination, crypto: source.crypto ? structuredClone(source.crypto) : undefined, @@ -223,6 +238,13 @@ async function resolveStagedProfileCandidate( return reference.candidate; } +async function resolvePairedStagedProfileCandidate( + candidate: BrowserProfileInferenceCandidate, +): Promise { + const references = Object.values(pruneProfileEvidence(await readStoredProfileEvidence())); + return pairedBrowserTransformCandidate(references.map((item) => item.candidate), candidate, true); +} + export async function resolveBrowserProfileCallableAnalysis( target: BrowserTarget, 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( target: BrowserTarget, candidateId: string, @@ -1088,8 +1125,41 @@ export async function proposeBrowserTransformProfile( ]); const evidence = await resolveProfileEvidence(snapshot, target, candidateId); const candidate = evidence.candidate; + const pairedCandidate = pairedBrowserTransformCandidate(snapshot.profileCandidates, candidate, true) + || await resolvePairedStagedProfileCandidate(candidate); const recordedCallable = callables.find((item) => item.id === 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' ? await callableOutputObservation(recordedCallable) : undefined; @@ -1099,7 +1169,7 @@ export async function proposeBrowserTransformProfile( if (!sameEvidenceTarget(candidate, target)) { 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'); } const pageUrl = frame?.url || callable.origin; @@ -1116,7 +1186,14 @@ export async function proposeBrowserTransformProfile( lastAccessed: tab.lastAccessed, }; 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, name: name || profile.name, @@ -1193,15 +1270,17 @@ export async function validateBrowserTransformProposal( comparisonMode: 'structure' | 'exact' = 'structure', candidateId?: string, ): Promise { - const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet); - const generated = applyTransformExecution(packet, execution); const candidate = candidateId ? (await resolveProfileEvidence( - await getBrowserRecording(normalized.target, 500, false), - normalized.target, + await getBrowserRecording(profile.target, 500, false), + profile.target, candidateId, )).candidate : undefined; + const { profile: normalized, execution } = await validateBrowserTransformProfile(profile, packet, { + direction: candidate?.direction, + }); + const generated = applyTransformExecution(packet, execution); if (candidate && !sameEvidenceTarget(candidate, normalized.target)) { throw new ExtensionError('profile_evidence_mismatch', '验证候选不属于明文网关绑定的页面'); } @@ -1237,6 +1316,7 @@ export async function validateBrowserTransformProposal( id: validationDraft.id, createdAt: validationDraft.createdAt, expiresAt: validationDraft.expiresAt, + directions: { request: normalizedProfile.request.enabled, response: normalizedProfile.response.enabled }, } : undefined, next: comparison ? comparison.equivalent @@ -1275,34 +1355,107 @@ export async function validateInferredBrowserTransformProfile( ); } +const preparingTabs = new Set(); + +interface PreparationCapture { + owner: DeepCaptureOwner; + trigger?: { captureId: string; nodeId: string }; + authorize(): void; +} + +async function captureMissingProfileCallable( + target: BrowserTarget, + candidate: BrowserProfileInferenceCandidate, + options?: PreparationCapture, +): Promise { + 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( target: BrowserTarget, candidateId: string, packet: BrowserTransformPacket, inputPaths?: string[], name?: string, + capture?: PreparationCapture, ): Promise { - const candidate = await resolveStagedProfileCandidate(target, candidateId); - const source = [candidate.source, ...candidate.sources] - .find((item) => item.callHandleId); - if (!source?.callHandleId) { - throw new ExtensionError( - 'gateway_capture_required', - '本次操作没有捕获到可回放的页面函数,请在原页面重新执行一次浏览器加解密检查', - ); + if (preparingTabs.has(target.tabId)) throw new ExtensionError('capture_busy', '当前标签页正在准备网关,请等待该操作完成'); + preparingTabs.add(target.tabId); + try { + const candidate = await resolveStagedProfileCandidate(target, candidateId); + const pairedCandidate = await resolvePairedStagedProfileCandidate(candidate); + const existingCallables = await listPageCallables(target); + const ensureCallable = async (item: BrowserProfileInferenceCandidate) => { + const source = [item.source, ...item.sources].find((value) => value.callHandleId) || item.source; + 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')); + if (existing) return existing; + if (item.status !== 'ready') return captureMissingProfileCallable(target, item, capture); + if (!source.callHandleId) throw new ExtensionError('gateway_capture_required', '候选缺少可复用的页面调用句柄,请重新执行 browser.crypto.inspect'); + return createRecordedPageCallable(target, { + callHandleId: source.callHandleId, + name: name || item.summary.slice(0, 120) || 'Captured page transform', + dynamicInputPaths: source.dynamicInputPaths, + }); + }; + // Retain an already-recorded opposite direction before re-triggering the page. + const directions = [candidate, pairedCandidate].filter((item): item is BrowserProfileInferenceCandidate => Boolean(item)) + .sort((left, right) => Number(right.status === 'ready') - Number(left.status === 'ready')); + let callable: BrowserPageCallable | undefined; + for (const direction of directions) { + 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); } - const existing = (await listPageCallables(target)) - .find((item) => item.provenance.eventId === source.eventId); - const callable = existing || await createRecordedPageCallable(target, { - callHandleId: source.callHandleId, - name: name || candidate.summary.slice(0, 120) || 'Captured page transform', - }); - return validateInferredBrowserTransformProfile( - target, - candidate.id, - callable.id, - packet, - inputPaths, - name, - ); } diff --git a/src/features/browser-crypto/adapters/adapters.test.ts b/src/features/browser-crypto/adapters/adapters.test.ts index a26b142..bb6ea05 100644 --- a/src/features/browser-crypto/adapters/adapters.test.ts +++ b/src/features/browser-crypto/adapters/adapters.test.ts @@ -91,11 +91,15 @@ describe('page crypto adapters', () => { const CBC = {}; const Pkcs7 = {}; const parsed: string[] = []; + const hexParsed: string[] = []; const cryptoJs = { - AES: { encrypt() { return 'cipher'; } }, + AES: { encrypt() { return 'cipher'; }, decrypt() { return 'plain'; } }, mode: { CBC }, 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 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(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', () => { diff --git a/src/features/browser-crypto/adapters/contract.ts b/src/features/browser-crypto/adapters/contract.ts index c639e6b..613b0dc 100644 --- a/src/features/browser-crypto/adapters/contract.ts +++ b/src/features/browser-crypto/adapters/contract.ts @@ -47,6 +47,13 @@ export interface CryptoAdapterInvocationPlan { arguments: BrowserRecordingCallArgument[]; callableKind?: CallableOperationKind; outputEncoding?: BrowserPageCallableValueEncoding; + replayInputs?: Array<{ + path: string; + name: string; + role: BrowserRecordingCallArgument['role']; + originalInput: unknown; + apply(args: unknown[], value: unknown): void; + }>; inputEvidence?(value: unknown): BrowserRecordingValueEvidence[]; outputEvidence?(value: unknown): BrowserRecordingValueEvidence[]; outputError?(value: unknown): string | undefined; diff --git a/src/features/browser-crypto/adapters/cryptojs.ts b/src/features/browser-crypto/adapters/cryptojs.ts index 7a41e2c..24ec86e 100644 --- a/src/features/browser-crypto/adapters/cryptojs.ts +++ b/src/features/browser-crypto/adapters/cryptojs.ts @@ -60,6 +60,8 @@ function describe( const cryptoJs = (scope.window as unknown as { CryptoJS?: Record }).CryptoJS || {}; const normalized = path.toLowerCase(); 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') ? 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('.encrypt') || normalized.includes('.decrypt')) roles = ['data', 'key', 'options']; 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 { crypto: { adapterId: cryptoJsManifest.id, @@ -78,12 +107,31 @@ function describe( mode: options.mode, padding: options.padding, inputEncoding: 'auto', - outputEncoding: encrypting ? 'base64' : 'auto', + outputEncoding, state: { model: 'stateless', phase: 'one-shot' }, }, inputIndex: 0, 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 + : {}; + nextArgs[2] = { ...options, iv: adaptWordArray(value, ownValue(args[2], 'iv')) }; + }, + }, + ] : undefined, arguments: args.slice(0, 8).map((value, index) => toolkit.argument( index, roles[index] || 'unknown', @@ -121,14 +169,7 @@ function describe( return output.slice(0, 48); }, adaptInput(value) { - 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); + return adaptData(value); }, }; } diff --git a/src/features/browser-crypto/inspect.test.ts b/src/features/browser-crypto/inspect.test.ts index b544b30..ceb1018 100644 --- a/src/features/browser-crypto/inspect.test.ts +++ b/src/features/browser-crypto/inspect.test.ts @@ -53,6 +53,7 @@ describe('atomic page crypto inspection', () => { }], traces: [], links: [], callables: [], profileCandidates: [{ id: 'candidate-1', direction: 'request', summary: 'login request', + status: 'ready', confidence: { score: 0.95, level: 'high' }, source: { eventId: 'event-1', callHandleId: 'handle-1' }, sources: [], @@ -117,6 +118,35 @@ describe('atomic page crypto inspection', () => { 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 () => { vi.useFakeTimers(); const startedAt = Date.now(); diff --git a/src/features/browser-crypto/inspect.ts b/src/features/browser-crypto/inspect.ts index 6662f83..a136f45 100644 --- a/src/features/browser-crypto/inspect.ts +++ b/src/features/browser-crypto/inspect.ts @@ -19,6 +19,7 @@ import { restorePageDialogCapture, } from '@/features/page-context/dialogs'; import { ExtensionError } from '@/shared/errors'; +import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft'; import type { BrowserRecordingEvent, BrowserRecordingSnapshot, @@ -219,11 +220,23 @@ export async function inspectPageCryptoOperation( if (!snapshot || !action) { throw new ExtensionError('crypto_inspection_incomplete', '未能完整执行页面加解密检查'); } - await stageBrowserProfileEvidence(snapshot); + await stageBrowserProfileEvidence(snapshot, action.node.semanticKey); const preparation = snapshot.profileCandidates - .filter((candidate) => candidate.direction === 'request' && [candidate.source, ...candidate.sources] + .filter((candidate) => [candidate.source, ...candidate.sources] .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 => 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); return { version: 1, @@ -243,17 +256,27 @@ export async function inspectPageCryptoOperation( }, postAction, gatewayPreparation: preparation ? { - state: 'ready', + state: preparationReady ? 'ready' : 'capture-required', candidateId: preparation.id, direction: preparation.direction, 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: { method: preparation.request.method, url: preparation.request.url, bodyFormat: preparation.request.bodyFormat, 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', next: '本次证据可用于分析,但不足以生成明文转换;继续使用当前页面,不要重新打开网站', diff --git a/src/features/browser-inference/inference.test.ts b/src/features/browser-inference/inference.test.ts index 6c740ac..cbec0b1 100644 --- a/src/features/browser-inference/inference.test.ts +++ b/src/features/browser-inference/inference.test.ts @@ -6,6 +6,7 @@ import type { } from '@/types/models'; import { inferBrowserTransformProfiles } from './inference'; import { buildRecordingLinks } from '@/features/browser-recording/timeline'; +import { pairedBrowserTransformCandidate } from '@/features/browser-transform/profile-draft'; function event(overrides: Partial & Pick): BrowserRecordingEvent { return { @@ -606,4 +607,112 @@ describe('browser profile inference', () => { })); 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); + }); }); diff --git a/src/features/browser-inference/inference.ts b/src/features/browser-inference/inference.ts index f7f982d..2fa070d 100644 --- a/src/features/browser-inference/inference.ts +++ b/src/features/browser-inference/inference.ts @@ -562,6 +562,7 @@ function buildCandidate( id: candidateId, recordingId: request.recordingId, traceId: request.traceId, + transactionId: request.channelId, target: { ...target }, direction: 'request', request: { @@ -698,6 +699,7 @@ function buildUnknownBoundaryCandidate( id: candidateId, recordingId: request.recordingId, traceId: request.traceId, + transactionId: request.channelId, target: { ...target }, direction: 'request', request: { @@ -812,6 +814,7 @@ function buildRequestGraphCandidate( id: candidateId, recordingId: request.recordingId, traceId: request.traceId, + transactionId: request.channelId, target: { ...target }, direction: 'request', request: { @@ -871,6 +874,7 @@ function buildRequestGraphCandidate( interface LinkedResponseSource { event: BrowserRecordingEvent; links: BrowserRecordingLink[]; + inputChains: BrowserRecordingLink[][]; stateLinks: BrowserRecordingLink[]; stateEvents: BrowserRecordingEvent[]; } @@ -896,12 +900,23 @@ function linkedResponseSources( const chain = [...current.links, link]; if (isReverseCryptoEvent(consumer)) { const previous = output.get(consumer.id); - if (!previous || chain.length < previous.links.length) { + if (!previous) { output.set(consumer.id, { event: consumer, links: chain, + inputChains: [chain], ...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; @@ -920,12 +935,24 @@ function buildResponseCandidate( response: BrowserRecordingEvent, source: LinkedResponseSource, ): 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 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 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 responseName = requestLabel(response); const sourceName = sourceLabel(source.event); @@ -938,7 +965,7 @@ function buildResponseCandidate( eventIds: [response.id], 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}`}`, kind: link.confidence === 'exact' ? 'exact-value' : 'message-boundary', strength: link.confidence === 'exact' ? 'proven' : 'supported', @@ -974,6 +1001,14 @@ function buildResponseCandidate( label: '页面仍保留本次解密调用的原函数、receiver 与固定参数模板', 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; if (exact) score += 40; @@ -989,8 +1024,10 @@ function buildResponseCandidate( } else { missing.push({ kind: 'business-callable', - label: exact && inputPath - ? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系' + label: dynamicInputs.length && !supportsDynamicInputs + ? `响应解密还依赖每次响应中的 ${dynamicInputs.map((path) => path.replace(/^\$input\.?/, '')).join('、')};需捕获上层业务函数以保留动态参数关系` + : exact && inputPath + ? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系' : '响应字段与页面解密调用尚未形成可回放的直接值链,请继续捕获当前解密现场', action: 'capture-business-function', }); @@ -1000,6 +1037,7 @@ function buildResponseCandidate( id: candidateId, recordingId: response.recordingId, traceId: response.traceId, + transactionId: response.channelId, target: { ...target }, direction: 'response', request: { @@ -1009,7 +1047,7 @@ function buildResponseCandidate( bodyFormat, destination: inputPath, serialization, - mappings: [{ sourceEventId: source.event.id, destination: inputPath, serialization }], + mappings: responseMappings, }, source: { eventId: source.event.id, @@ -1017,6 +1055,7 @@ function buildResponseCandidate( operation: source.event.operation, crypto: source.event.crypto, callHandleId: source.event.callHandleId, + dynamicInputPaths: replayReady ? replayInputPaths : undefined, arguments: argumentRoles, destination: inputPath, serialization, @@ -1027,6 +1066,7 @@ function buildResponseCandidate( operation: source.event.operation, crypto: source.event.crypto, callHandleId: source.event.callHandleId, + dynamicInputPaths: replayReady ? replayInputPaths : undefined, arguments: argumentRoles, destination: inputPath, serialization, @@ -1038,7 +1078,7 @@ function buildResponseCandidate( : `已定位 ${responseName} 到 ${sourceName} 的响应解密链`, flow: [ inputPath ? `${responseName} · ${inputPath}` : responseName, - ...(source.links.length > 1 ? [`${source.links.length - 1} 个响应准备步骤`] : []), + ...(primaryLinks.length > 1 ? [`${primaryLinks.length - 1} 个响应准备步骤`] : []), sourceName, '明文响应', ], diff --git a/src/features/browser-recording/RecordingWorkspace.tsx b/src/features/browser-recording/RecordingWorkspace.tsx index 742e4e7..9a1e71b 100644 --- a/src/features/browser-recording/RecordingWorkspace.tsx +++ b/src/features/browser-recording/RecordingWorkspace.tsx @@ -19,7 +19,12 @@ import { BrowserTransformWorkspace, type BrowserTransformSuggestionSeed, } 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, success?: string) => Promise; const DEEP_CAPTURE_AVAILABLE = !import.meta.env.FIREFOX; @@ -30,7 +35,6 @@ interface RecordingWorkspaceProps { run: RunTask; gatewayShared: boolean; onShareGateway: () => Promise; - initialMode?: 'gateway' | 'recording' | 'deep'; } const KIND_LABELS: Record = { @@ -65,6 +69,14 @@ function confidenceLabel(candidate: BrowserProfileInferenceCandidate): string { 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']) { if (kind === 'navigation') return ; if (kind === 'interaction') return ; @@ -183,9 +195,8 @@ export function RecordingWorkspace({ run, gatewayShared, onShareGateway, - initialMode = 'recording', }: RecordingWorkspaceProps) { - const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>(initialMode); + const [workspaceMode, setWorkspaceMode] = useState<'gateway' | 'recording' | 'deep'>('recording'); const [autoArmRequest, setAutoArmRequest] = useState(0); const [autoRecoveryRequest, setAutoRecoveryRequest] = useState(0); const [recoveryProfileId, setRecoveryProfileId] = useState(''); @@ -202,6 +213,9 @@ export function RecordingWorkspace({ const [callableArguments, setCallableArguments] = useState('[]'); const [callableResult, setCallableResult] = useState(); const [gatewaySuggestion, setGatewaySuggestion] = useState(); + const [pendingGatewayBinding, setPendingGatewayBinding] = useState(); + const [captureCandidate, setCaptureCandidate] = useState(); + useEffect(() => { setCaptureCandidate(undefined); }, [tab?.id]); const load = useCallback(async () => { const tabId = tab?.id; @@ -284,6 +298,8 @@ export function RecordingWorkspace({ setSelectedTraceId(''); setSelectedEventId(''); setCallableResult(undefined); + setPendingGatewayBinding(undefined); + setCaptureCandidate(undefined); }, captureValues ? '录制已开始;短时样本仅保留在本次浏览器会话,页面跳转后会自动接续' : '录制已开始,将跨页面记录业务执行链'); const stop = () => run(async () => { @@ -295,6 +311,8 @@ export function RecordingWorkspace({ if (!recordingTarget) return; setSnapshot(await request('recording.clear', recordingTarget)); setCallableResult(undefined); + setPendingGatewayBinding(undefined); + setCaptureCandidate(undefined); }, '录制与录制型页面函数已清空'); const createCallable = () => run(async () => { @@ -357,9 +375,22 @@ export function RecordingWorkspace({ const outgoingLinks = selectedEvent ? snapshot?.links.filter((link) => link.fromEventId === selectedEvent.id) || [] : []; const incomingLinks = selectedEvent ? snapshot?.links.filter((link) => link.toEventId === selectedEvent.id) || [] : []; const traceCandidates = snapshot?.profileCandidates.filter((candidate) => candidate.traceId === selectedTraceId) || []; - const selectedCandidate = traceCandidates.find((candidate) => ( - candidate.sources.some((source) => source.eventId === selectedEventId) || candidate.request.eventId === selectedEventId - )) || traceCandidates[0]; + const sourceCandidates = traceCandidates.filter((candidate) => candidate.source.eventId === selectedEventId); + const selectedCandidate = sourceCandidates.length === 1 ? sourceCandidates[0] : undefined; + 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 ? snapshot?.events.find((event) => event.id === selectedCandidate.source.eventId) : undefined; @@ -382,7 +413,9 @@ export function RecordingWorkspace({ }; const continueInference = (candidate: BrowserProfileInferenceCandidate) => { + setCaptureCandidate(candidate); setRecoveryProfileId(''); + setSelectedTraceId(candidate.traceId); setSelectedEventId(candidate.capturePlan?.matcherEventId || (candidate.sources.length > 1 ? candidate.request.eventId : candidate.source.eventId)); setAutoArmRequest((current) => current + 1); @@ -390,6 +423,7 @@ export function RecordingWorkspace({ }; const openRecovery = (profileId: string) => { + setCaptureCandidate(undefined); setRecoveryProfileId(profileId); setAutoRecoveryRequest((current) => current + 1); setWorkspaceMode('deep'); @@ -407,6 +441,37 @@ export function RecordingWorkspace({ capturedSample?: CapturedCallableSample, ) => { 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 boundaryEvent = snapshot?.events.find((item) => item.id === candidate.request.eventId); const profile = await request('transform.profile.save', createBrowserTransformProfileInput( @@ -414,15 +479,21 @@ export function RecordingWorkspace({ sourceEvent, callable, 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 ? { ...current, - callables: [...current.callables.filter((item) => item.id !== callable.id), callable], + callables: [...current.callables.filter((item) => !callableIds.has(item.id)), ...callables], } : current); + setPendingGatewayBinding(undefined); + setCaptureCandidate(undefined); setGatewaySuggestion((current) => ({ revision: (current?.revision || 0) + 1, candidate, - callable, + callables, profile, sampleBody: capturedSample?.body || shortSample(candidate.direction === 'response' ? boundaryEvent : sourceEvent), sampleLabel: capturedSample?.label || (candidate.direction === 'response' && boundaryEvent @@ -447,17 +518,20 @@ export function RecordingWorkspace({ if (!currentSnapshot) throw new Error('没有可用的录制现场'); const target = currentSnapshot.status.target; 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) { callable = await request('callable.create', { ...target, source: 'recording', callHandleId: candidate.source.callHandleId, name: `${candidate.source.crypto?.algorithm || candidate.source.crypto?.operation || candidate.source.operation} 页面函数`, + dynamicInputPaths: candidate.source.dynamicInputPaths, }); } await openSuggestedGateway(candidate, callable); - }, '已根据录制证据生成并保存明文网关'); + }, '当前方向已完成;存在配对方向时将继续捕获并合并为一个网关'); return
@@ -521,11 +595,12 @@ export function RecordingWorkspace({ {!traceEvents.length ?
当前 Trace 没有事件
: traceEvents.map((event, index) => { const linked = snapshot?.links.some((link) => link.fromEventId === event.id || link.toEventId === event.id); const callableAvailable = eventAvailableInDocument(event, currentDocumentId, documentAvailable); + const flowDirection = recordingEventDirection(event, traceCandidates); return
; @@ -535,7 +610,7 @@ export function RecordingWorkspace({