feat: advance browser agent integration workflows

This commit is contained in:
go0p
2026-08-06 15:11:35 +08:00
committed by go0p
parent af5a4db694
commit 0c8e1c7b69
215 changed files with 35137 additions and 5442 deletions
@@ -39,6 +39,9 @@ const safeArguments: BrowserRecordingCallArgument[] = [
const cryptoJsAES = {
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.encrypt', algorithm: 'AES.encrypt',
} as const;
const cryptoJsAESDecrypt = {
adapterId: 'cryptojs', providerKind: 'library', family: 'symmetric', operation: 'AES.decrypt', algorithm: 'AES.decrypt',
} as const;
const webCryptoAES = {
adapterId: 'webcrypto', providerKind: 'native', family: 'symmetric', operation: 'encrypt', algorithm: 'AES-GCM',
} as const;
@@ -115,6 +118,114 @@ describe('browser profile inference', () => {
expect(candidates[0].aiContext.valuePolicy).toBe('metadata-only');
});
it('captures the business envelope when a request uses a structured crypto result subfield', () => {
const crypto = event({
id: 'structured-crypto', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: cryptoJsAES,
callHandleId: 'structured-handle', callableCapable: true, arguments: safeArguments,
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 8 }],
outputs: [{ path: '$output.ciphertext', fingerprint: 'encoded-child', encoding: 'hex', byteLength: 16 }],
});
const request = event({
id: 'structured-request', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
url: 'https://example.test/submit',
inputs: [{ path: '$body:json.password', fingerprint: 'encoded-child', encoding: 'hex', byteLength: 16 }],
});
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 },
events: [crypto, request],
links: [link({
id: 'structured-output-link',
fromEventId: crypto.id,
fromPath: '$output.ciphertext',
toEventId: request.id,
toPath: '$body:json.password',
})],
});
expect(candidate).toMatchObject({
status: 'capture-required',
request: { destination: 'body.password', serialization: 'json-field' },
capturePlan: {
transaction: {
version: 2,
prerequisites: [],
request: { expectedDestinations: ['body.password'], bodyFormat: 'json' },
},
},
});
expect(candidate.missing[0].label).toContain('上层业务函数');
});
it('compiles an evidence-linked online key request into an ordered request transaction', () => {
const keyRequest = event({
id: 'key-request', sequence: 1, kind: 'fetch', operation: 'request', direction: 'send',
channelId: 'fetch-key', method: 'GET', url: 'http://127.0.0.1:82/encrypt/server_generate_key.php',
});
const crypto = event({
id: 'crypto-online-key', sequence: 3, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
callHandleId: 'handle-online-key', callableCapable: true, arguments: safeArguments,
inputs: [
{ path: '$key', fingerprint: 'server-key', encoding: 'base64', byteLength: 24 },
{ path: '$options.iv', fingerprint: 'server-iv', encoding: 'base64', byteLength: 24 },
],
outputs: [{ path: '$output:string', fingerprint: 'server-cipher', encoding: 'text', byteLength: 88 }],
});
// Fetch body readers emit their final structured response after the consumer resumes.
const keyResponse = event({
id: 'key-response', sequence: 4, kind: 'fetch', operation: 'response', direction: 'receive',
channelId: 'fetch-key', method: 'GET', url: 'http://127.0.0.1:82/encrypt/server_generate_key.php',
statusCode: 200, dataType: 'Object', resultByteLength: 76,
outputs: [
{ path: '$body.aes_key', fingerprint: 'server-key', encoding: 'base64', byteLength: 24 },
{ path: '$body.aes_iv', fingerprint: 'server-iv', encoding: 'base64', byteLength: 24 },
],
});
const finalRequest = event({
id: 'server-aes-request', sequence: 5, kind: 'fetch', operation: 'request', direction: 'send',
channelId: 'fetch-final', method: 'POST', url: 'http://127.0.0.1:82/encrypt/aesserver.php',
inputs: [{ path: '$body:json.encryptedData', fingerprint: 'server-cipher', encoding: 'text', byteLength: 88 }],
});
const events = [keyRequest, crypto, keyResponse, finalRequest];
const candidates = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
events,
links: buildRecordingLinks(events),
});
const candidate = candidates.find((item) => item.request.eventId === finalRequest.id);
expect(candidate).toMatchObject({
status: 'capture-required',
capturePlan: {
transaction: {
version: 2,
prerequisites: [{
boundary: 'fetch',
method: 'GET',
url: keyRequest.url,
requestBodyFormat: 'none',
response: {
statusCode: 200,
url: keyResponse.url,
bodyFormat: 'json',
requiredPaths: ['body.aes_key', 'body.aes_iv'],
},
}],
request: {
boundary: 'fetch',
method: 'POST',
url: finalRequest.url,
expectedDestinations: ['body.encryptedData'],
bodyFormat: 'json',
},
},
},
});
expect(candidate?.summary).toContain('在线前置请求');
expect(candidate?.evidence).toContainEqual(expect.objectContaining({
kind: 'response-boundary', strength: 'proven', eventIds: [keyRequest.id, keyResponse.id, crypto.id],
}));
});
it('follows a bounded exact-value chain through an intermediate encoder', () => {
const crypto = event({
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
@@ -146,6 +257,76 @@ describe('browser profile inference', () => {
expect(cryptoCandidate?.flow).toContain('1 个中间转换');
});
it('keeps the field destination when an envelope also links to the whole request body', () => {
const crypto = event({
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'SHA256', crypto: cryptoJsHmac,
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
outputs: [{ path: '$output', fingerprint: 'digest', encoding: 'text', byteLength: 64 }],
});
const envelope = event({
id: 'form-envelope', sequence: 2, kind: 'transform', operation: 'URLSearchParams',
inputs: [{ path: '$input:form.encryptedData', fingerprint: 'digest', encoding: 'text', byteLength: 64 }],
outputs: [
{ path: '$output', fingerprint: 'form-body', encoding: 'text', byteLength: 96 },
{ path: '$output:form.encryptedData', fingerprint: 'digest', encoding: 'text', byteLength: 64 },
{ path: '$output:form.channel', fingerprint: 'channel', encoding: 'text', byteLength: 7 },
],
});
const request = event({
id: 'request-1', sequence: 3, kind: 'fetch', operation: 'request', method: 'POST',
url: 'https://example.test/session',
inputs: [
{ path: '$body', fingerprint: 'form-body', encoding: 'text', byteLength: 96 },
{ path: '$body:form.encryptedData', fingerprint: 'digest', encoding: 'text', byteLength: 64 },
{ path: '$body:form.channel', fingerprint: 'channel', encoding: 'text', byteLength: 7 },
],
});
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 },
events: [crypto, envelope, request],
links: [
link({
id: 'crypto-envelope',
fromEventId: crypto.id,
fromPath: '$output',
toEventId: envelope.id,
toPath: '$input:form.encryptedData',
}),
link({
id: 'envelope-body',
fromEventId: envelope.id,
fromPath: '$output',
toEventId: request.id,
toPath: '$body',
}),
link({
id: 'envelope-field',
fromEventId: envelope.id,
fromPath: '$output:form.encryptedData',
toEventId: request.id,
toPath: '$body:form.encryptedData',
}),
link({
id: 'envelope-channel',
fromEventId: envelope.id,
fromPath: '$output:form.channel',
toEventId: request.id,
toPath: '$body:form.channel',
}),
],
});
expect(candidate.request).toMatchObject({
destination: 'body.encryptedData',
serialization: 'form-field',
});
expect(candidate.status).toBe('capture-required');
expect(candidate.evidence).toContainEqual(expect.objectContaining({
id: 'evidence-link-envelope-field',
toPath: '$body:form.encryptedData',
}));
});
it.each([
['$body:form.encryptedData', 'body.encryptedData'],
['$query.signature', 'query.signature'],
@@ -284,6 +465,7 @@ describe('browser profile inference', () => {
expect(candidate.request.mappings.map((item) => item.destination)).toEqual([
'body.encryptedData', 'body.encryptedKey', 'body.encryptedIv',
]);
expect(candidate.request.bodyFormat).toBe('json');
expect(candidate.status).toBe('capture-required');
expect(candidate.summary).toContain('3 个密码调用');
expect(candidate.missing[0].label).toContain('随机 Key、IV、Nonce');
@@ -336,7 +518,12 @@ describe('browser profile inference', () => {
it('traces canonical JSON through a signature and Axios into a request header', () => {
const canonical = event({
id: 'canonical-json', sequence: 1, kind: 'transform', operation: 'JSON.stringify',
transform: { category: 'serializer', provider: 'native', phase: 'output' },
transform: {
adapterId: 'native.json',
providerKind: 'native',
category: 'serializer',
phase: 'output',
},
inputs: [{ path: '$input.account', fingerprint: 'account', encoding: 'text', byteLength: 5 }],
outputs: [{ path: '$output', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
});
@@ -348,7 +535,12 @@ describe('browser profile inference', () => {
});
const axios = event({
id: 'axios', sequence: 3, kind: 'transform', operation: 'axios.request',
transform: { category: 'request-builder', provider: 'axios', phase: 'boundary' },
transform: {
adapterId: 'axios',
providerKind: 'library',
category: 'request-builder',
phase: 'boundary',
},
inputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
outputs: [{ path: '$headers.X-Signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
});
@@ -369,4 +561,49 @@ describe('browser profile inference', () => {
expect(candidate.flow).toContain('1 个输入准备步骤');
expect(candidate.flow).toContain('1 个中间转换');
});
it.each([
['response observed before decrypt', 1, 2],
['response body reader completed after decrypt', 3, 2],
])('infers a ready response gateway when %s', (_label, responseSequence, decryptSequence) => {
const response = event({
id: 'encrypted-response', sequence: responseSequence, kind: 'fetch', operation: 'response',
direction: 'receive', method: 'GET', url: 'https://example.test/api/profile', statusCode: 200,
outputs: [{ path: '$body:json.encryptedData', fingerprint: 'response-cipher', encoding: 'text', byteLength: 88 }],
});
const decrypt = event({
id: 'decrypt-response', sequence: decryptSequence, kind: 'crypto', operation: 'AES.decrypt',
crypto: cryptoJsAESDecrypt,
callHandleId: 'decrypt-handle', callableCapable: true,
arguments: safeArguments,
inputs: [{ path: '$input', fingerprint: 'response-cipher', encoding: 'text', byteLength: 88 }],
outputs: [{ path: '$output', fingerprint: 'response-plain', encoding: 'text', byteLength: 42 }],
});
const events = [response, decrypt];
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
events,
links: buildRecordingLinks(events),
});
expect(candidate).toMatchObject({
direction: 'response',
status: 'ready',
request: {
eventId: response.id,
destination: 'body.encryptedData',
serialization: 'json-field',
},
source: { eventId: decrypt.id, callHandleId: 'decrypt-handle' },
confidence: { level: 'high', score: 100 },
});
expect(candidate.pipeline).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'context.read', source: 'body.encryptedData' }),
expect.objectContaining({ kind: 'output.write', destination: 'body' }),
]));
expect(candidate.evidence).toContainEqual(expect.objectContaining({
kind: 'response-boundary', strength: 'proven',
}));
expect(candidate.aiContext.requiredDecision).toBe('none');
});
});
+504 -13
View File
@@ -1,4 +1,6 @@
import type {
BrowserPageCallableTransaction,
BrowserPageCallableBodyFormat,
BrowserProfileInferenceCandidate,
BrowserProfileInferenceEvidence,
BrowserProfileInferenceMissingStep,
@@ -7,7 +9,7 @@ import type {
BrowserRecordingLink,
BrowserTarget,
} from '@/types/models';
import { cryptoEventLabel, isForwardCryptoEvent } from '@/features/browser-crypto/model';
import { cryptoEventLabel, isForwardCryptoEvent, isReverseCryptoEvent } from '@/features/browser-crypto/model';
import { inferBusinessFrameHints } from './stack-hints';
const MAX_LINK_DEPTH = 8;
@@ -26,12 +28,30 @@ interface LinkedSource {
stateEvents: BrowserRecordingEvent[];
inputLinks: BrowserRecordingLink[];
inputEvents: BrowserRecordingEvent[];
onlineDependencies: OnlineDependency[];
}
function isRequestEvent(event: BrowserRecordingEvent): boolean {
interface OnlineDependency {
request: BrowserRecordingEvent;
response: BrowserRecordingEvent;
links: BrowserRecordingLink[];
step?: BrowserPageCallableTransaction['prerequisites'][number];
unsupportedReason?: string;
}
type RequestBoundaryEvent = BrowserRecordingEvent & {
kind: 'fetch' | 'xhr' | 'form' | 'beacon';
operation: 'request';
};
function isRequestEvent(event: BrowserRecordingEvent): event is RequestBoundaryEvent {
return ['fetch', 'xhr', 'form', 'beacon'].includes(event.kind) && event.operation === 'request';
}
function isResponseEvent(event: BrowserRecordingEvent): boolean {
return ['fetch', 'xhr'].includes(event.kind) && event.operation === 'response';
}
function isCandidateSource(event: BrowserRecordingEvent): boolean {
return isForwardCryptoEvent(event);
}
@@ -51,6 +71,152 @@ function requestMapping(path?: string): { destination?: string; serialization?:
return {};
}
function requestPathSpecificity(path?: string): number {
const mapping = requestMapping(path);
if (!mapping.destination) return 0;
return mapping.destination === 'body' ? 1 : 2;
}
function preferLinkedChain(
candidate: BrowserRecordingLink[],
current: BrowserRecordingLink[],
source: BrowserRecordingEvent,
request: BrowserRecordingEvent,
): boolean {
const fingerprintMatches = (links: BrowserRecordingLink[]): boolean => {
const requestPath = links.at(-1)?.toPath;
const requestInput = request.inputs.find((item) => item.path === requestPath);
return Boolean(requestInput?.fingerprint && source.outputs.some((item) => item.fingerprint === requestInput.fingerprint));
};
const candidateMatches = fingerprintMatches(candidate);
const currentMatches = fingerprintMatches(current);
if (candidateMatches !== currentMatches) return candidateMatches;
if (candidate.length !== current.length) return candidate.length < current.length;
const candidatePath = candidate.at(-1)?.toPath;
const currentPath = current.at(-1)?.toPath;
const specificity = requestPathSpecificity(candidatePath) - requestPathSpecificity(currentPath);
if (specificity !== 0) return specificity > 0;
return (candidatePath || '').localeCompare(currentPath || '') < 0;
}
function requestBodyFormat(
request: BrowserRecordingEvent,
serializations: Array<BrowserProfileInferenceSerialization | undefined>,
): BrowserPageCallableBodyFormat {
if (serializations.includes('form-field')
|| ['FormData', 'URLSearchParams'].includes(request.dataType || '')
|| request.inputs.some((item) => item.path.startsWith('$body:form.'))) return 'form';
if (serializations.includes('json-field')
|| request.inputs.some((item) => item.path === '$body:json' || item.path.startsWith('$body:json.'))) return 'json';
const contentType = request.inputs.find((item) => item.path.toLowerCase() === '$headers.content-type')?.preview?.toLowerCase();
if (contentType?.includes('application/x-www-form-urlencoded')) return 'form';
if (contentType?.includes('application/json')) return 'json';
return 'raw';
}
function responseBodyFormat(
response: BrowserRecordingEvent,
serializations: Array<BrowserProfileInferenceSerialization | undefined>,
): BrowserPageCallableBodyFormat {
if (response.outputs.some((item) => item.path.startsWith('$body.') || item.path.startsWith('$body:json.'))
|| ['Object', 'object', 'Array'].includes(response.dataType || '')) return 'json';
return requestBodyFormat({ ...response, inputs: response.outputs }, serializations);
}
function boundedReplayBytes(observed: number | undefined, floor: number, ceiling: number): number {
const value = Number.isFinite(observed) ? Math.max(0, Number(observed)) : 0;
return Math.min(ceiling, Math.max(floor, Math.ceil(value * 4)));
}
function responseDependencyPath(path: string): string | undefined {
if (path === '$body' || path === '$body:json') return 'body';
const suffix = path.startsWith('$body:json.')
? path.slice('$body:json.'.length)
: path.startsWith('$body.') ? path.slice('$body.'.length) : undefined;
if (suffix === undefined) return undefined;
const structuralPath = suffix.replace(/:(?:json|form)(?:\.|$).*$/, '');
if (structuralPath) return `body.${structuralPath}`;
return undefined;
}
function onlineDependencies(
event: BrowserRecordingEvent,
eventsById: Map<string, BrowserRecordingEvent>,
incoming: Map<string, BrowserRecordingLink[]>,
): OnlineDependency[] {
const dependencies = new Map<string, OnlineDependency>();
const queue: Array<{ event: BrowserRecordingEvent; links: BrowserRecordingLink[]; depth: number }> = [
{ event, links: [], depth: 0 },
];
const visited = new Set<string>([event.id]);
const events = [...eventsById.values()];
while (queue.length) {
const current = queue.shift()!;
if (current.depth >= MAX_LINK_DEPTH) continue;
for (const link of incoming.get(current.event.id) || []) {
if (link.kind !== 'value' || link.confidence !== 'exact') continue;
const source = eventsById.get(link.fromEventId);
if (!source || source.traceId !== event.traceId) continue;
const chain = [link, ...current.links];
if (isResponseEvent(source) && source.channelId) {
const request = events.find((candidate) => (
candidate.traceId === event.traceId
&& candidate.channelId === source.channelId
&& candidate.kind === source.kind
&& isRequestEvent(candidate)
&& candidate.sequence < event.sequence
));
if (!request) continue;
const key = `${request.kind}\0${request.channelId}`;
const previous = dependencies.get(key);
const requiredPaths = [...new Set([
...(previous?.step?.response.requiredPaths || []),
...chain.map((item) => responseDependencyPath(item.fromPath)).filter((item): item is string => Boolean(item)),
])];
const unsupportedReason = request.kind !== 'fetch'
? `在线依赖使用 ${request.kind.toUpperCase()},当前只能安全重放 Fetch 前置请求`
: !request.url || !source.url || !requiredPaths.length
? '在线依赖缺少可验证的请求 URL、响应 URL 或响应字段路径'
: source.statusCode === undefined || source.statusCode < 100 || source.statusCode > 599
? '在线依赖缺少可验证的响应状态码'
: undefined;
const step = unsupportedReason ? undefined : {
boundary: 'fetch' as const,
method: (request.method || 'GET').toUpperCase(),
url: request.url!,
requestBodyFormat: ['GET', 'HEAD'].includes((request.method || 'GET').toUpperCase()) && !request.byteLength
? 'none' as const
: requestBodyFormat(request, []),
maxRequestBodyBytes: boundedReplayBytes(request.byteLength, 16 * 1_024, 1 * 1_024 * 1_024),
response: {
statusCode: source.statusCode!,
url: source.url!,
bodyFormat: responseBodyFormat(source, []),
maxBodyBytes: boundedReplayBytes(source.resultByteLength, 64 * 1_024, 1 * 1_024 * 1_024),
requiredPaths,
},
};
dependencies.set(key, {
request,
response: source,
links: [...(previous?.links || []), ...chain].filter((item, index, values) => (
values.findIndex((candidate) => candidate.id === item.id) === index
)),
step,
unsupportedReason,
});
continue;
}
if (source.kind !== 'transform' || visited.has(source.id)) continue;
visited.add(source.id);
queue.push({ event: source, links: chain, depth: current.depth + 1 });
}
}
return [...dependencies.values()].sort((left, right) => (
left.request.sequence - right.request.sequence || left.request.id.localeCompare(right.request.id)
));
}
function requestLabel(event: BrowserRecordingEvent): string {
const method = event.method || 'GET';
if (!event.url) return method;
@@ -87,7 +253,7 @@ function linkedSources(
const queue: Array<{ eventId: string; links: BrowserRecordingLink[]; depth: number }> = [
{ eventId: request.id, links: [], depth: 0 },
];
const visitedDepth = new Map<string, number>([[request.id, 0]]);
const visitedDepth = new Map<string, number>([[`${request.id}\0`, 0]]);
while (queue.length) {
const current = queue.shift()!;
if (current.depth >= MAX_LINK_DEPTH) continue;
@@ -98,18 +264,21 @@ function linkedSources(
const chain = [link, ...current.links];
if (isCandidateSource(source)) {
const previous = output.get(source.id);
if (!previous || chain.length < previous.links.length) {
if (!previous || preferLinkedChain(chain, previous.links, source, request)) {
output.set(source.id, {
event: source,
links: chain,
...stateSequence(source, eventsById, incoming),
...inputLineage(source, eventsById, incoming),
onlineDependencies: onlineDependencies(source, eventsById, incoming),
});
}
}
const depth = current.depth + 1;
if ((visitedDepth.get(source.id) ?? Number.POSITIVE_INFINITY) <= depth) continue;
visitedDepth.set(source.id, depth);
const path = chain.at(-1)?.toPath || '';
const visitKey = `${source.id}\0${path}`;
if ((visitedDepth.get(visitKey) ?? Number.POSITIVE_INFINITY) <= depth) continue;
visitedDepth.set(visitKey, depth);
queue.push({ eventId: source.id, links: chain, depth });
}
}
@@ -188,6 +357,7 @@ function temporalSource(
links: [],
...stateSequence(source, eventsById, incoming),
...inputLineage(source, eventsById, incoming),
onlineDependencies: onlineDependencies(source, eventsById, incoming),
} : undefined;
}
@@ -201,15 +371,43 @@ function capturePlan(
matcherEventId: string,
events: BrowserRecordingEvent[],
expectedDestinations: Array<string | undefined>,
transaction?: BrowserPageCallableTransaction,
) {
return {
matcherEventId,
frameHints: inferBusinessFrameHints(events),
expectedDestinations: expectedDestinations.filter((item): item is string => Boolean(item)),
sourceCount: events.length,
transaction,
};
}
function requestTransaction(
request: BrowserRecordingEvent,
expectedDestinations: string[],
dependencies: OnlineDependency[],
): BrowserPageCallableTransaction | undefined {
if (!request.url || !isRequestEvent(request) || !expectedDestinations.length
|| dependencies.some((dependency) => !dependency.step)) return undefined;
return {
version: 2,
prerequisites: dependencies.map((dependency) => dependency.step!),
request: {
boundary: request.kind,
method: (request.method || 'GET').toUpperCase(),
url: request.url,
expectedDestinations: [...new Set(expectedDestinations)],
bodyFormat: requestBodyFormat(request, []),
},
inputMode: 'auto',
};
}
function directCallableOutputCompatible(link?: BrowserRecordingLink): boolean {
if (!link) return false;
return link.fromPath === '$output' || link.fromPath === '$output:string';
}
function buildCandidate(
target: BrowserTarget,
request: BrowserRecordingEvent,
@@ -218,8 +416,11 @@ function buildCandidate(
const exact = source.links.length > 0 && source.links.every((link) => link.confidence === 'exact');
const finalLink = source.links.at(-1);
const { destination, serialization } = requestMapping(finalLink?.toPath);
const bodyFormat = requestBodyFormat(request, [serialization]);
const hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
const replayReady = hasCallable && Boolean(destination) && source.links.length === 1;
const replayReady = hasCallable && Boolean(destination) && source.links.length === 1
&& directCallableOutputCompatible(finalLink)
&& source.onlineDependencies.length === 0;
const argumentRoles = source.event.arguments || [];
const evidence: BrowserProfileInferenceEvidence[] = [{
id: `evidence-request-${request.id}`,
@@ -263,7 +464,11 @@ function buildCandidate(
? `已关联规范化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
: category === 'request-builder'
? `已关联请求准备步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
: `已关联序列化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`;
: category === 'compression'
? `已关联压缩步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
: category === 'encoding'
? `已关联编码步骤 ${transform?.operation || link.fromPath} 与密码调用输入`
: `已关联序列化步骤 ${transform?.operation || link.fromPath} 与密码调用输入`;
evidence.push({
id: `evidence-input-transform-${link.id || `${source.event.id}-${index}`}`,
kind: 'transform-lineage',
@@ -288,6 +493,17 @@ function buildCandidate(
label: '页面仍保留本次调用的原函数、receiver 与固定参数模板',
eventIds: [source.event.id],
});
source.onlineDependencies.forEach((dependency, index) => evidence.push({
id: `evidence-online-dependency-${dependency.request.id}-${source.event.id}-${index}`,
kind: 'response-boundary',
strength: dependency.step ? 'proven' : 'supported',
label: dependency.step
? `${requestLabel(dependency.request)} 的响应值进入密码调用;回放必须先完成该在线请求`
: `${requestLabel(dependency.request)} 的响应值进入密码调用,但尚不能安全重放:${dependency.unsupportedReason}`,
eventIds: [dependency.request.id, dependency.response.id, source.event.id],
fromPath: dependency.links[0]?.fromPath,
toPath: dependency.links.at(-1)?.toPath,
}));
let score = 20;
if (exact) score += 40;
@@ -299,7 +515,16 @@ function buildCandidate(
const missing: BrowserProfileInferenceMissingStep[] = [];
let status: BrowserProfileInferenceCandidate['status'];
if (!exact || !destination) {
if (source.onlineDependencies.length) {
status = 'capture-required';
missing.push({
kind: 'business-callable',
label: source.onlineDependencies.every((dependency) => Boolean(dependency.step))
? `已证明 ${source.onlineDependencies.length} 个在线前置请求;需要捕获完整业务函数,才能在同一浏览器会话中刷新动态参数并截获最终请求`
: `发现在线前置请求,但存在当前无法安全回放的边界:${source.onlineDependencies.find((dependency) => !dependency.step)?.unsupportedReason}`,
action: 'capture-business-function',
});
} else if (!exact || !destination) {
status = 'capture-required';
missing.push({
kind: 'business-callable',
@@ -343,6 +568,7 @@ function buildCandidate(
eventId: request.id,
method: request.method || 'GET',
url: request.url || '',
bodyFormat,
destination,
serialization,
mappings: [{ sourceEventId: source.event.id, destination, serialization }],
@@ -369,13 +595,16 @@ function buildCandidate(
}],
status,
confidence: { score, level: confidenceLevel(score) },
summary: replayReady
summary: source.onlineDependencies.length
? `已确认 ${sourceName} 依赖 ${source.onlineDependencies.length} 个在线前置请求,并将输出写入 ${destination || requestName}`
: replayReady
? `已确认 ${sourceName} 的输出进入 ${destination},可生成明文网关`
: exact && destination
? `已确认 ${sourceName} 的输出进入 ${destination}`
: `已定位 ${sourceName} 与 ${requestName},可继续捕获完整页面业务封装`,
flow: [
'明文输入(待确认)',
...(source.onlineDependencies.length ? [`${source.onlineDependencies.length} 个在线前置请求`] : []),
...(source.inputEvents.length ? [`${source.inputEvents.length} 个输入准备步骤`] : []),
sourceName,
...(source.links.length > 1 ? [`${source.links.length - 1} 个中间转换`] : []),
@@ -403,6 +632,7 @@ function buildCandidate(
source.event.id,
[...new Map([...source.inputEvents, ...source.stateEvents].map((event) => [event.id, event])).values()],
[destination],
destination ? requestTransaction(request, [destination], source.onlineDependencies) : undefined,
)
: undefined,
aiContext: {
@@ -411,6 +641,7 @@ function buildCandidate(
eventId: request.id,
method: request.method || 'GET',
url: safeUrlMetadata(request.url) || '',
bodyFormat,
destination,
serialization,
},
@@ -462,6 +693,7 @@ function buildUnknownBoundaryCandidate(
arguments: [],
};
const score = stackAvailable ? 45 : 35;
const bodyFormat = requestBodyFormat(request, []);
return {
id: candidateId,
recordingId: request.recordingId,
@@ -472,6 +704,7 @@ function buildUnknownBoundaryCandidate(
eventId: request.id,
method: request.method || 'GET',
url: request.url || '',
bodyFormat,
mappings: [],
},
source,
@@ -498,6 +731,7 @@ function buildUnknownBoundaryCandidate(
eventId: request.id,
method: request.method || 'GET',
url: safeUrlMetadata(request.url) || '',
bodyFormat,
},
source: {
eventId: request.id,
@@ -529,6 +763,7 @@ function buildRequestGraphCandidate(
destination: source.destination,
serialization: source.serialization,
}));
const bodyFormat = requestBodyFormat(request, mappings.map((mapping) => mapping.serialization));
const evidenceById = new Map<string, BrowserProfileInferenceEvidence>();
for (const member of members) {
for (const item of member.evidence) evidenceById.set(item.id, item);
@@ -538,10 +773,39 @@ function buildRequestGraphCandidate(
const score = Math.max(0, Math.min(90, Math.min(...members.map((member) => member.confidence.score)) - 10));
const requestName = requestLabel(request);
const destinations = graphSources.map((source) => source.destination).filter((item): item is string => Boolean(item));
const dependencyMap = new Map<string, OnlineDependency>();
for (const dependency of sources.flatMap((source) => source.onlineDependencies)) {
const key = `${dependency.request.kind}\0${dependency.request.channelId || dependency.request.id}`;
const previous = dependencyMap.get(key);
if (!previous) {
dependencyMap.set(key, dependency);
continue;
}
const requiredPaths = [...new Set([
...(previous.step?.response.requiredPaths || []),
...(dependency.step?.response.requiredPaths || []),
])];
dependencyMap.set(key, {
...previous,
links: [...previous.links, ...dependency.links].filter((item, index, values) => (
values.findIndex((candidate) => candidate.id === item.id) === index
)),
step: previous.step && dependency.step ? {
...previous.step,
response: { ...previous.step.response, requiredPaths },
} : undefined,
unsupportedReason: previous.unsupportedReason || dependency.unsupportedReason,
});
}
const dependencies = [...dependencyMap.values()].sort((left, right) => (
left.request.sequence - right.request.sequence || left.request.id.localeCompare(right.request.id)
));
const candidateId = `candidate-graph-${request.id}-${graphSources.map((source) => source.eventId).join('-')}`;
const missing: BrowserProfileInferenceMissingStep[] = [{
kind: 'business-callable',
label: '同一请求包含多个相关密码调用;需要捕获上层业务封装,才能保持随机 Key、IV、Nonce 与各输出字段在每次回放中一致',
label: dependencies.length
? `同一请求包含多个相关密码调用和 ${dependencies.length} 个在线前置请求;需要捕获完整业务函数,才能保持动态响应、Key、IV、Nonce 与输出字段的一致关系`
: '同一请求包含多个相关密码调用;需要捕获上层业务封装,才能保持随机 Key、IV、Nonce 与各输出字段在每次回放中一致',
action: 'capture-business-function',
}];
return {
@@ -554,6 +818,7 @@ function buildRequestGraphCandidate(
eventId: request.id,
method: request.method || 'GET',
url: request.url || '',
bodyFormat,
mappings,
},
source: primary.source,
@@ -561,10 +826,11 @@ function buildRequestGraphCandidate(
status: 'capture-required',
confidence: { score, level: confidenceLevel(score) },
summary: allMapped
? `已确认 ${graphSources.length} 个密码调用分别进入 ${destinations.join('、')},需要保留它们的动态值关系`
? `已确认 ${graphSources.length} 个密码调用分别进入 ${destinations.join('、')},需要保留它们的动态值关系${dependencies.length ? `及 ${dependencies.length} 个在线前置请求` : ''}`
: `已识别 ${graphSources.length} 个密码调用与 ${requestName} 的请求级数据流`,
flow: [
'明文与动态参数',
...(dependencies.length ? [`${dependencies.length} 个在线前置请求`] : []),
`${graphSources.length} 个关联密码调用`,
allMapped ? `${requestName} · ${destinations.length} 个字段` : requestName,
],
@@ -579,6 +845,7 @@ function buildRequestGraphCandidate(
primary.source.eventId,
[...new Map(sources.flatMap((source) => [...source.inputEvents, ...source.stateEvents]).map((event) => [event.id, event])).values()],
destinations,
allMapped ? requestTransaction(request, destinations, dependencies) : undefined,
),
aiContext: {
valuePolicy: 'metadata-only',
@@ -586,6 +853,7 @@ function buildRequestGraphCandidate(
eventId: request.id,
method: request.method || 'GET',
url: safeUrlMetadata(request.url) || '',
bodyFormat,
},
source: primary.aiContext.source,
sources: graphSources.map((source) => ({
@@ -600,11 +868,229 @@ function buildRequestGraphCandidate(
};
}
interface LinkedResponseSource {
event: BrowserRecordingEvent;
links: BrowserRecordingLink[];
stateLinks: BrowserRecordingLink[];
stateEvents: BrowserRecordingEvent[];
}
function linkedResponseSources(
response: BrowserRecordingEvent,
eventsById: Map<string, BrowserRecordingEvent>,
incoming: Map<string, BrowserRecordingLink[]>,
outgoing: Map<string, BrowserRecordingLink[]>,
): LinkedResponseSource[] {
const output = new Map<string, LinkedResponseSource>();
const queue: Array<{ eventId: string; links: BrowserRecordingLink[]; depth: number }> = [
{ eventId: response.id, links: [], depth: 0 },
];
const visited = new Map<string, number>([[response.id, 0]]);
while (queue.length) {
const current = queue.shift()!;
if (current.depth >= MAX_LINK_DEPTH) continue;
for (const link of outgoing.get(current.eventId) || []) {
if (link.kind === 'state') continue;
const consumer = eventsById.get(link.toEventId);
if (!consumer || consumer.traceId !== response.traceId || consumer.id === response.id) continue;
const chain = [...current.links, link];
if (isReverseCryptoEvent(consumer)) {
const previous = output.get(consumer.id);
if (!previous || chain.length < previous.links.length) {
output.set(consumer.id, {
event: consumer,
links: chain,
...stateSequence(consumer, eventsById, incoming),
});
}
}
const depth = current.depth + 1;
if ((visited.get(consumer.id) ?? Number.POSITIVE_INFINITY) <= depth) continue;
visited.set(consumer.id, depth);
queue.push({ eventId: consumer.id, links: chain, depth });
}
}
return [...output.values()].sort((left, right) => (
left.links.length - right.links.length || left.event.sequence - right.event.sequence
));
}
function buildResponseCandidate(
target: BrowserTarget,
response: BrowserRecordingEvent,
source: LinkedResponseSource,
): BrowserProfileInferenceCandidate {
const firstLink = source.links[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 hasCallable = Boolean(source.event.callHandleId && source.event.callableCapable);
const replayReady = exact && source.links.length === 1 && Boolean(inputPath) && hasCallable;
const argumentRoles = source.event.arguments || [];
const responseName = requestLabel(response);
const sourceName = sourceLabel(source.event);
const candidateId = `candidate-response-${response.id}-${source.event.id}`;
const evidence: BrowserProfileInferenceEvidence[] = [{
id: `evidence-response-${response.id}`,
kind: 'response-boundary',
strength: 'proven',
label: `响应读取边界:${responseName}${response.statusCode === undefined ? '' : ` · ${response.statusCode}`}`,
eventIds: [response.id],
fromPath: firstLink?.fromPath,
}];
source.links.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',
label: link.confidence === 'correlated'
? '响应值经过同一 Worker / MessagePort 通道后进入页面解密调用'
: index === 0 && inputPath
? `${inputPath} 的密文指纹精确进入页面解密链`
: `响应解密链精确匹配 ${link.fromPath} -> ${link.toPath}`,
eventIds: [link.fromEventId, link.toEventId],
fromPath: link.fromPath,
toPath: link.toPath,
}));
if (source.stateLinks.length) {
evidence.push({
id: `evidence-response-state-${source.event.id}`,
kind: 'state-sequence',
strength: 'supported',
label: `同一解密会话已关联 ${source.stateEvents.length} 个阶段`,
eventIds: source.stateEvents.map((event) => event.id),
});
}
evidence.push({
id: `evidence-response-trace-${response.id}-${source.event.id}`,
kind: 'trace-order',
strength: 'supported',
label: '响应读取与解密调用位于同一业务 Trace,密文值关系不依赖异步回调的记录先后',
eventIds: [response.id, source.event.id],
});
if (hasCallable) evidence.push({
id: `evidence-response-callable-${source.event.id}`,
kind: 'callable',
strength: 'proven',
label: '页面仍保留本次解密调用的原函数、receiver 与固定参数模板',
eventIds: [source.event.id],
});
let score = 20;
if (exact) score += 40;
if (inputPath) score += 10;
if (hasCallable) score += 15;
if (argumentRoles.length) score += 10;
score += 5;
score = Math.min(100, score);
const missing: BrowserProfileInferenceMissingStep[] = [];
let status: BrowserProfileInferenceCandidate['status'] = 'capture-required';
if (replayReady) {
status = 'ready';
} else {
missing.push({
kind: 'business-callable',
label: exact && inputPath
? '已定位响应解密链;还需捕获上层业务函数,才能保留解码、解压与多阶段解密关系'
: '响应字段与页面解密调用尚未形成可回放的直接值链,请继续捕获当前解密现场',
action: 'capture-business-function',
});
}
return {
id: candidateId,
recordingId: response.recordingId,
traceId: response.traceId,
target: { ...target },
direction: 'response',
request: {
eventId: response.id,
method: response.method || 'GET',
url: response.url || '',
bodyFormat,
destination: inputPath,
serialization,
mappings: [{ sourceEventId: source.event.id, destination: inputPath, serialization }],
},
source: {
eventId: source.event.id,
kind: source.event.kind,
operation: source.event.operation,
crypto: source.event.crypto,
callHandleId: source.event.callHandleId,
arguments: argumentRoles,
destination: inputPath,
serialization,
},
sources: [{
eventId: source.event.id,
kind: source.event.kind,
operation: source.event.operation,
crypto: source.event.crypto,
callHandleId: source.event.callHandleId,
arguments: argumentRoles,
destination: inputPath,
serialization,
}],
status,
confidence: { score, level: confidenceLevel(score) },
summary: replayReady
? `已确认 ${responseName} 的 ${inputPath} 进入 ${sourceName},可生成响应明文网关`
: `已定位 ${responseName} 到 ${sourceName} 的响应解密链`,
flow: [
inputPath ? `${responseName} · ${inputPath}` : responseName,
...(source.links.length > 1 ? [`${source.links.length - 1} 个响应准备步骤`] : []),
sourceName,
'明文响应',
],
pipeline: [
{ id: `${candidateId}-input`, kind: 'context.read', label: '读取线上响应密文', source: inputPath || 'body' },
{ id: `${candidateId}-call`, kind: 'page.call', label: sourceName, callHandleId: source.event.callHandleId },
{ id: `${candidateId}-output`, kind: 'output.write', label: '写入明文响应', destination: 'body' },
],
evidence,
missing,
capturePlan: status === 'capture-required'
? capturePlan(source.event.id, source.stateEvents, [inputPath])
: undefined,
aiContext: {
valuePolicy: 'metadata-only',
request: {
eventId: response.id,
method: response.method || 'GET',
url: safeUrlMetadata(response.url) || '',
bodyFormat,
destination: inputPath,
serialization,
},
source: {
eventId: source.event.id,
kind: source.event.kind,
operation: source.event.operation,
crypto: source.event.crypto,
scriptUrl: safeUrlMetadata(source.event.scriptUrl),
arguments: argumentRoles,
},
sources: [{
eventId: source.event.id,
operation: source.event.operation,
crypto: source.event.crypto,
destination: inputPath,
}],
evidenceIds: evidence.map((item) => item.id),
requiredDecision: status === 'ready' ? 'none' : 'capture-business-callable',
},
};
}
export function inferBrowserTransformProfiles(input: BrowserProfileInferenceInput): BrowserProfileInferenceCandidate[] {
const events = [...input.events].sort((left, right) => left.sequence - right.sequence);
const eventsById = new Map(events.map((event) => [event.id, event]));
const incoming = new Map<string, BrowserRecordingLink[]>();
for (const link of input.links) incoming.set(link.toEventId, [...(incoming.get(link.toEventId) || []), link]);
const outgoing = new Map<string, BrowserRecordingLink[]>();
for (const link of input.links) {
incoming.set(link.toEventId, [...(incoming.get(link.toEventId) || []), link]);
outgoing.set(link.fromEventId, [...(outgoing.get(link.fromEventId) || []), link]);
}
const output: BrowserProfileInferenceCandidate[] = [];
for (const request of events.filter(isRequestEvent)) {
const exactSources = linkedSources(request, eventsById, incoming);
@@ -615,6 +1101,11 @@ export function inferBrowserTransformProfiles(input: BrowserProfileInferenceInpu
? buildRequestGraphCandidate(input.target, request, sources)
: buildUnknownBoundaryCandidate(input.target, request));
}
for (const response of events.filter(isResponseEvent)) {
for (const source of linkedResponseSources(response, eventsById, incoming, outgoing)) {
output.push(buildResponseCandidate(input.target, response, source));
}
}
return output
.sort((left, right) => right.confidence.score - left.confidence.score
|| left.source.eventId.localeCompare(right.source.eventId))