Files
yaklang-chrome-extension/src/features/browser-inference/inference.test.ts
T

719 lines
36 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
BrowserRecordingCallArgument,
BrowserRecordingEvent,
BrowserRecordingLink,
} 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<BrowserRecordingEvent> & Pick<BrowserRecordingEvent, 'id' | 'sequence' | 'kind' | 'operation'>): BrowserRecordingEvent {
return {
timestamp: 1_000 + overrides.sequence,
recordingId: 'recording-1',
traceId: 'trace-1',
inputs: [],
outputs: [],
sensitiveCaptured: false,
...overrides,
};
}
function link(overrides: Pick<BrowserRecordingLink, 'id' | 'fromEventId' | 'fromPath' | 'toEventId' | 'toPath'>): BrowserRecordingLink {
return { traceId: 'trace-1', kind: 'value', confidence: 'exact', ...overrides };
}
const safeArguments: BrowserRecordingCallArgument[] = [
{ index: 0, role: 'data', dataType: 'string', byteLength: 52, replaceable: true, retained: true },
{ index: 1, role: 'key', dataType: 'Object', byteLength: 16, replaceable: false, retained: true },
{
index: 2,
role: 'options',
dataType: 'Object',
replaceable: false,
retained: true,
summary: 'mode=CBC padding=Pkcs7 ivBytes=16',
},
];
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;
const cryptoJsHmac = {
adapterId: 'cryptojs', providerKind: 'library', family: 'mac', operation: 'HmacSHA256', algorithm: 'HmacSHA256',
} as const;
describe('browser profile inference', () => {
it('turns a JSEncrypt RSA result mapped to a form field into a ready profile', () => {
const rsa = event({
id: 'rsa-1', sequence: 1, kind: 'crypto', operation: 'encrypt',
crypto: {
adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA',
padding: 'PKCS1-v1_5', inputEncoding: 'utf8', outputEncoding: 'base64',
key: { kind: 'public', bits: 1024, fingerprint: 'key-fingerprint' },
},
callHandleId: 'rsa-handle', callableCapable: true,
arguments: [{ index: 0, role: 'data', dataType: 'string', byteLength: 44, replaceable: true, retained: true }],
inputs: [{ path: '$input', fingerprint: 'plain-json', encoding: 'text', byteLength: 44 }],
outputs: [{ path: '$output', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
});
const request = event({
id: 'request-rsa', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/rsa.php',
inputs: [{ path: '$body:form.data', fingerprint: 'rsa-cipher', encoding: 'text', byteLength: 172 }],
});
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 },
events: [rsa, request],
links: [link({ id: 'rsa-link', fromEventId: rsa.id, fromPath: '$output', toEventId: request.id, toPath: '$body:form.data' })],
});
expect(candidate).toMatchObject({
status: 'ready',
request: {
destination: 'body.data',
serialization: 'form-field',
mappings: [{ sourceEventId: 'rsa-1', destination: 'body.data', serialization: 'form-field' }],
},
source: {
eventId: 'rsa-1',
callHandleId: 'rsa-handle',
crypto: { adapterId: 'jsencrypt', algorithm: 'RSA', padding: 'PKCS1-v1_5' },
},
confidence: { level: 'high', score: 100 },
});
expect(candidate.summary).toContain('JSEncrypt RSA');
});
it('turns an exact CryptoJS-to-JSON-field link into a high-confidence capture candidate', () => {
const crypto = event({
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
inputs: [{ path: '$input', fingerprint: 'plain', encoding: 'text', byteLength: 52 }],
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
});
const request = event({
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
inputs: [{ path: '$body:json.encryptedData', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
});
const candidates = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
events: [crypto, request],
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath: '$body:json.encryptedData' })],
});
expect(candidates).toHaveLength(1);
expect(candidates[0]).toMatchObject({
status: 'ready',
request: { destination: 'body.encryptedData', serialization: 'json-field' },
source: { eventId: 'crypto-1', callHandleId: 'handle-1', arguments: safeArguments },
confidence: { level: 'high', score: 100 },
});
expect(candidates[0].evidence.some((item) => item.kind === 'exact-value' && item.strength === 'proven')).toBe(true);
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,
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
outputs: [{ path: '$output', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
});
const encoder = event({
id: 'encode-1', sequence: 2, kind: 'transform', operation: 'base64.encode',
inputs: [{ path: '$input', fingerprint: 'raw-cipher', encoding: 'base64', byteLength: 64 }],
outputs: [{ path: '$output', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
});
const request = event({
id: 'request-1', sequence: 3, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/login',
inputs: [{ path: '$headers.x-signature', fingerprint: 'encoded-cipher', encoding: 'text', byteLength: 88 }],
});
const candidates = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 },
events: [crypto, encoder, request],
links: [
link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output', toEventId: encoder.id, toPath: '$input' }),
link({ id: 'link-2', fromEventId: encoder.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
],
});
const cryptoCandidate = candidates.find((item) => item.source.eventId === crypto.id);
expect(candidates).toHaveLength(1);
expect(cryptoCandidate?.request.destination).toBe('header.x-signature');
expect(cryptoCandidate?.evidence.filter((item) => item.kind === 'exact-value')).toHaveLength(2);
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'],
])('maps generic serialized request evidence %s to %s', (toPath, destination) => {
const crypto = event({
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
callHandleId: 'handle-1', callableCapable: true, arguments: safeArguments,
outputs: [{ path: '$output:string', fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
});
const request = event({
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
url: 'https://example.test/session',
inputs: [{ path: toPath, fingerprint: 'cipher', encoding: 'text', byteLength: 88 }],
});
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 },
events: [crypto, request],
links: [link({ id: 'link-1', fromEventId: crypto.id, fromPath: '$output:string', toEventId: request.id, toPath })],
});
expect(candidate.request.destination).toBe(destination);
expect(candidate.confidence.level).toBe('high');
expect(candidate.request.serialization).toBe(toPath.startsWith('$body:form.') ? 'form-field' : 'query');
});
it('keeps a same-trace temporal guess low-confidence and never includes captured values in AI context', () => {
const crypto = event({
id: 'crypto-1', sequence: 1, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
inputPreview: 'plain-password', outputPreview: 'secret-signature', arguments: safeArguments,
});
const request = event({
id: 'request-1', sequence: 2, kind: 'fetch', operation: 'request', method: 'POST',
url: 'https://example.test/api/login?token=secret-query-value&mode=fast#private-fragment',
inputPreview: '{"password":"plain-password"}',
});
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [crypto, request], links: [] });
expect(candidate.status).toBe('capture-required');
expect(candidate.confidence.level).toBe('low');
expect(JSON.stringify(candidate)).not.toContain('plain-password');
expect(JSON.stringify(candidate)).not.toContain('secret-signature');
expect(JSON.stringify(candidate.aiContext)).not.toContain('secret-query-value');
expect(JSON.stringify(candidate.aiContext)).not.toContain('private-fragment');
expect(candidate.aiContext.request.url).toBe('https://example.test/api/login?mode&token');
expect(candidate.summary).toContain('可继续捕获完整页面业务封装');
expect(candidate.missing[0].label).not.toMatch(/更短|重新录制一次|操作太长/);
});
it('continues from an unknown request boundary even when no known crypto library is visible', () => {
const request = event({
id: 'opaque-request', sequence: 1, kind: 'fetch', operation: 'request', method: 'POST',
url: 'https://example.test/opaque', stack: 'at pack (https://example.test/chunk-a.js:1:42)',
});
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 }, events: [request], links: [],
});
expect(candidate).toMatchObject({ status: 'capture-required', source: { operation: 'unknown-business-envelope' } });
expect(candidate.summary).toContain('算法或库未知不影响继续捕获');
expect(candidate.aiContext.requiredDecision).toBe('capture-business-callable');
expect(candidate.capturePlan).toMatchObject({ matcherEventId: request.id, sourceCount: 1 });
});
it('treats a Worker round trip as correlated evidence rather than an exact value proof', () => {
const crypto = event({
id: 'crypto', sequence: 1, kind: 'crypto', operation: 'encrypt', crypto: webCryptoAES,
outputs: [{ path: '$output', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
});
const send = event({
id: 'worker-send', sequence: 2, kind: 'worker', operation: 'worker.postMessage', direction: 'send', channelId: 'channel-1',
inputs: [{ path: '$message', fingerprint: 'plain-to-worker', encoding: 'base64', byteLength: 32 }],
});
const receive = event({
id: 'worker-receive', sequence: 3, kind: 'worker', operation: 'worker.message', direction: 'receive', channelId: 'channel-1',
outputs: [{ path: '$message', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
});
const request = event({
id: 'request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/submit',
inputs: [{ path: '$body:json.payload', fingerprint: 'worker-result', encoding: 'text', byteLength: 64 }],
});
const links: BrowserRecordingLink[] = [
link({ id: 'value-in', fromEventId: crypto.id, fromPath: '$output', toEventId: send.id, toPath: '$message' }),
{ id: 'channel', traceId: 'trace-1', kind: 'channel', confidence: 'correlated', fromEventId: send.id, fromPath: '$message', toEventId: receive.id, toPath: '$message' },
link({ id: 'value-out', fromEventId: receive.id, fromPath: '$message', toEventId: request.id, toPath: '$body:json.payload' }),
];
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 }, events: [crypto, send, receive, request], links,
});
expect(candidate.status).toBe('capture-required');
expect(candidate.request.destination).toBe('body.payload');
expect(candidate.evidence.some((item) => item.kind === 'message-boundary' && item.strength === 'supported')).toBe(true);
});
it('groups hybrid AES and RSA outputs into one request graph and refuses unsafe independent replay', () => {
const sources = [
event({
id: 'aes-data', sequence: 1, kind: 'crypto', operation: 'AES.encrypt', crypto: cryptoJsAES,
callHandleId: 'aes-handle', callableCapable: true, arguments: safeArguments,
stack: 'at aes (https://example.test/vendor/aes.js:1:1)\n at _0xPacket (https://example.test/app.js:40:2)\n at onclick (https://example.test/app.js:90:1)',
outputs: [{ path: '$output:string', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 }],
}),
event({
id: 'rsa-key', sequence: 2, kind: 'crypto', operation: 'encrypt',
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
callHandleId: 'rsa-key-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
stack: 'at rsa (https://example.test/vendor/rsa.js:1:1)\n at _0xPacket (https://example.test/app.js:51:2)\n at onclick (https://example.test/app.js:90:1)',
outputs: [{ path: '$output', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 }],
}),
event({
id: 'rsa-iv', sequence: 3, kind: 'crypto', operation: 'encrypt',
crypto: { adapterId: 'jsencrypt', providerKind: 'library', family: 'asymmetric', operation: 'encrypt', algorithm: 'RSA' },
callHandleId: 'rsa-iv-handle', callableCapable: true, arguments: safeArguments.slice(0, 1),
stack: 'at rsa (https://example.test/vendor/rsa.js:8:1)\n at _0xPacket (https://example.test/app.js:58:2)\n at onclick (https://example.test/app.js:90:1)',
outputs: [{ path: '$output', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 }],
}),
];
const request = event({
id: 'hybrid-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/encrypt/aesrsa.php',
inputs: [
{ path: '$body:json.encryptedData', fingerprint: 'cipher-data', encoding: 'text', byteLength: 88 },
{ path: '$body:json.encryptedKey', fingerprint: 'cipher-key', encoding: 'text', byteLength: 172 },
{ path: '$body:json.encryptedIv', fingerprint: 'cipher-iv', encoding: 'text', byteLength: 172 },
],
});
const links = sources.map((source, index) => link({
id: `hybrid-link-${index}`,
fromEventId: source.id,
fromPath: source.outputs[0].path,
toEventId: request.id,
toPath: request.inputs[index].path,
}));
const [candidate] = inferBrowserTransformProfiles({ target: { tabId: 7, frameId: 0 }, events: [...sources, request], links });
expect(candidate.sources).toHaveLength(3);
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');
expect(candidate.capturePlan).toMatchObject({
matcherEventId: sources[0].id,
sourceCount: 3,
expectedDestinations: ['body.encryptedData', 'body.encryptedKey', 'body.encryptedIv'],
});
expect(candidate.capturePlan?.frameHints[0]).toMatchObject({ functionName: '_0xPacket', support: 3 });
});
it('keeps a stateful signature session as one request source and exposes its ordered stages', () => {
const crypto = (operation: string, phase: 'create' | 'update' | 'final') => ({
adapterId: 'jsrsasign', providerKind: 'library' as const, family: 'signature' as const, operation,
algorithm: 'SHA256withRSA',
state: { model: 'session' as const, correlationId: 'signature-session-1', phase },
});
const create = event({
id: 'signature-create', sequence: 1, kind: 'crypto', operation: 'Signature.create', crypto: crypto('Signature.create', 'create'),
});
const update = event({
id: 'signature-update', sequence: 2, kind: 'crypto', operation: 'Signature.updateString', crypto: crypto('Signature.updateString', 'update'),
});
const final = event({
id: 'signature-final', sequence: 3, kind: 'crypto', operation: 'Signature.sign', crypto: crypto('Signature.sign', 'final'),
outputs: [{ path: '$output', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
});
const request = event({
id: 'signed-request', sequence: 4, kind: 'fetch', operation: 'request', method: 'POST', url: 'https://example.test/api/signed',
inputs: [{ path: '$headers.x-signature', fingerprint: 'signature', encoding: 'hex', byteLength: 64 }],
});
const links: BrowserRecordingLink[] = [
{ id: 'state-create-update', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: create.id, fromPath: '$state.create', toEventId: update.id, toPath: '$state.update' },
{ id: 'state-update-final', traceId: 'trace-1', kind: 'state', confidence: 'correlated', fromEventId: update.id, fromPath: '$state.update', toEventId: final.id, toPath: '$state.final' },
link({ id: 'signature-request', fromEventId: final.id, fromPath: '$output', toEventId: request.id, toPath: '$headers.x-signature' }),
];
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 }, events: [create, update, final, request], links,
});
expect(candidate.sources).toHaveLength(1);
expect(candidate.source.eventId).toBe(final.id);
expect(candidate.request.destination).toBe('header.x-signature');
expect(candidate.evidence).toContainEqual(expect.objectContaining({
kind: 'state-sequence', eventIds: [create.id, update.id, final.id],
}));
expect(candidate.capturePlan).toMatchObject({ sourceCount: 3 });
});
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: {
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 }],
});
const signature = event({
id: 'signature', sequence: 2, kind: 'crypto', operation: 'HmacSHA256', crypto: cryptoJsHmac,
callHandleId: 'signature-handle', callableCapable: true, arguments: safeArguments,
inputs: [{ path: '$input', fingerprint: 'canonical', encoding: 'text', byteLength: 42 }],
outputs: [{ path: '$output', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
});
const axios = event({
id: 'axios', sequence: 3, kind: 'transform', operation: 'axios.request',
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 }],
});
const request = event({
id: 'request', sequence: 4, kind: 'xhr', operation: 'request', method: 'POST', url: 'https://example.test/api/order',
inputs: [{ path: '$headers.x-signature', fingerprint: 'signed', encoding: 'hex', byteLength: 64 }],
});
const events = [canonical, signature, axios, request];
const [candidate] = inferBrowserTransformProfiles({
target: { tabId: 7, frameId: 0 }, events, links: buildRecordingLinks(events),
});
expect(candidate.sources).toHaveLength(1);
expect(candidate.request).toMatchObject({ destination: 'header.x-signature', serialization: 'header' });
expect(candidate.evidence).toContainEqual(expect.objectContaining({
kind: 'transform-lineage', strength: 'proven', eventIds: [canonical.id, signature.id],
}));
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');
});
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);
});
});