mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 20:51:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user